mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 |
No files matched your search
@@ -97,21 +97,16 @@ 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 @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.
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
# Because the platform payloads are versions of the same package, they reuse
|
||||
# that single trusted-publisher configuration.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
@@ -161,11 +156,10 @@ jobs:
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs the wrapper's first-run downloader builds
|
||||
# (internal/release/release.go asset-name scheme) as an anonymous client
|
||||
# — install.sh/install.ps1 and `zero update` read them too. This also
|
||||
# blocks publishing while the repo is still private: private release
|
||||
# assets 404 without auth, exactly as they would for an installing user.
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -179,64 +173,12 @@ jobs:
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: zero-*
|
||||
path: dist/artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# 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: |
|
||||
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
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
@@ -27,10 +27,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
@@ -41,10 +37,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-windows-command-runner.exe
|
||||
/zero-windows-sandbox-setup.exe
|
||||
/zero-skills
|
||||
/.zero-binary-version
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
@@ -54,4 +47,3 @@ docs/superpowers/
|
||||
# Termux/Android build artifacts
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
@@ -1 +1 @@
|
||||
{".":"0.3.0"}
|
||||
{".":"0.1.0"}
|
||||
@@ -132,7 +132,7 @@ description: Run the project's benchmark suite and summarize the deltas.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. During an agent run, Zero loads the default skills directory before plugin skill roots; earlier roots win name collisions silently. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
@@ -235,13 +235,6 @@ Servers are merged from user and project configs (project wins on conflicts). To
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
Project MCP config remains supported for shared team servers, but a project layer
|
||||
that changes an existing server's target (`url`, `command`, or `args`) does not
|
||||
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
|
||||
or use a new server name. OAuth tokens are bound to the resolved server identity,
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
@@ -289,7 +282,7 @@ A plugin is enabled by being present in the plugins directory and disabled by re
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
|
||||
-105
@@ -6,111 +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.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
|
||||
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
|
||||
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
|
||||
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
|
||||
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
|
||||
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
|
||||
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
|
||||
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
|
||||
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
|
||||
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
|
||||
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
|
||||
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
|
||||
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
|
||||
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
|
||||
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
|
||||
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
|
||||
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
|
||||
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
|
||||
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
|
||||
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
|
||||
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
|
||||
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
|
||||
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
|
||||
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
|
||||
@@ -49,15 +49,34 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package is a small wrapper whose platform build (Linux and macOS on
|
||||
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
|
||||
installs as an optional dependency straight from the npm registry — no install
|
||||
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
|
||||
no trust or approval steps. Installs that skip optional dependencies
|
||||
(`--omit=optional`) still work: the wrapper fetches the binary from the
|
||||
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.
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
|
||||
+19
-1
@@ -38,7 +38,25 @@ 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)。
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
|
||||
+22
-75
@@ -2,7 +2,6 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -40,11 +39,6 @@ function resolveHelper(packageRoot, name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
|
||||
// installs vendor the helpers inside the platform package instead (a helpers/
|
||||
// directory next to the binary, found by the Go side on its own), so this
|
||||
// manifest is usually empty — it only matters for a downloaded-binary install
|
||||
// whose wrapper still carries helper dependencies.
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
@@ -55,85 +49,38 @@ function localControlHelperManifest(packageRoot) {
|
||||
return JSON.stringify({ version: 1, helpers });
|
||||
}
|
||||
|
||||
// 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 = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = join(dirname(manifestPath), zeroBinaryName());
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
// Mirrors the release matrix (internal/release/release.go): the platforms a
|
||||
// GitHub Release asset exists for. android maps to the linux asset; there is
|
||||
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
|
||||
function releaseAssetAvailable() {
|
||||
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
|
||||
process.platform
|
||||
];
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
|
||||
if (!platform || !arch) return false;
|
||||
return !(platform === 'windows' && arch === 'arm64');
|
||||
}
|
||||
|
||||
// Fallback for installs without a platform package (--omit=optional, package
|
||||
// managers that skip optionalDependencies): fetch the binary next to the
|
||||
// wrapper with the same checksum-verified downloader that used to run as
|
||||
// postinstall. Deliberately NOT cached on failure — a transient network error
|
||||
// self-heals on the next run — so this retries on every invocation until a
|
||||
// binary is in place. Platforms with no release asset skip the attempt.
|
||||
function downloadMissingBinary(packageRoot) {
|
||||
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
|
||||
console.error(
|
||||
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
|
||||
);
|
||||
spawnSync(process.execPath, [downloadScript], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
const downloaded = join(packageRoot, zeroBinaryName());
|
||||
if (existsSync(downloaded)) return downloaded;
|
||||
downloadMissingBinary(packageRoot);
|
||||
return existsSync(downloaded) ? downloaded : null;
|
||||
}
|
||||
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary is available for this install.\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.\n' +
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
'If this platform has no prebuilt binary, build from source:\n' +
|
||||
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
|
||||
@@ -1,787 +0,0 @@
|
||||
# How Zero Works
|
||||
|
||||
This document explains Zero's architecture at a high level: the layers, module
|
||||
boundaries, data flow, and runtime concepts behind the interactive TUI and the
|
||||
headless `zero exec` mode.
|
||||
|
||||
It is written as a progressive map: each high-level section introduces the
|
||||
concepts you need for orientation, then points to the later section that explains
|
||||
the mechanics in more detail.
|
||||
|
||||
## Big Picture
|
||||
|
||||
Zero is a Go CLI application with two main user-facing surfaces:
|
||||
|
||||
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
|
||||
tool cards, permission prompts, model/provider pickers, and session controls.
|
||||
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
|
||||
same agent loop and tools, but writes text, JSON, or stream-JSON events.
|
||||
|
||||
Both surfaces converge on the same core agent runtime:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User[User / automation] --> CLI[cmd/zero -> internal/cli]
|
||||
|
||||
CLI --> TUI[Interactive TUI<br/>internal/tui]
|
||||
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
|
||||
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
|
||||
|
||||
TUI --> Agent[Agent loop<br/>internal/agent]
|
||||
Exec --> Agent
|
||||
ACP --> Agent
|
||||
|
||||
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
|
||||
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
|
||||
|
||||
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
|
||||
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
|
||||
Registry --> MCP[MCP tools<br/>internal/mcp]
|
||||
Registry --> Specialists[Specialist tools<br/>internal/specialist]
|
||||
Registry --> Plugins[Plugin tools<br/>internal/plugins]
|
||||
Registry --> LocalControl[Browser / terminal / desktop helpers]
|
||||
|
||||
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
|
||||
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
|
||||
Agent --> Sessions[Session event store<br/>internal/sessions]
|
||||
```
|
||||
|
||||
The important split is:
|
||||
|
||||
- `internal/tui` is the **presentation layer**. For the mechanics, see
|
||||
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
|
||||
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
|
||||
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
|
||||
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
|
||||
and [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
|
||||
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
|
||||
[Tools and Capabilities](#tools-and-capabilities), and
|
||||
[Permission and Sandbox Model](#permission-and-sandbox-model).
|
||||
- `internal/sessions` is the **durable event log** used for resume, fork,
|
||||
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
|
||||
|
||||
## Architecture Spine
|
||||
|
||||
The easiest way to understand Zero is not as a set of independent packages, but
|
||||
as one spine with several mechanisms attached to it:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
|
||||
Run --> Agent[Agent loop<br/>turns, tools, recovery]
|
||||
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
|
||||
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
|
||||
Events --> Surface
|
||||
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
|
||||
|
||||
Config[Config / guidelines / skills] --> Run
|
||||
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
|
||||
```
|
||||
|
||||
The **TUI + agent loop** is the main story because it explains the normal user
|
||||
experience: type a prompt, watch the model stream, approve tool use, see file
|
||||
changes, and receive a final answer. Other mechanisms are still important, but
|
||||
most of them attach to the same spine:
|
||||
|
||||
| Mechanism | Where it attaches | What it changes |
|
||||
| --- | --- | --- |
|
||||
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
|
||||
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
|
||||
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
|
||||
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
|
||||
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
|
||||
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
|
||||
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
|
||||
|
||||
So this document focuses first on the TUI/agent-loop bridge, then explains the
|
||||
supporting mechanisms in terms of where they connect to that bridge.
|
||||
|
||||
Use this as the reading path:
|
||||
|
||||
| If you see this concept in a high-level flow | Read this detail section |
|
||||
| --- | --- |
|
||||
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
|
||||
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
|
||||
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
|
||||
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
|
||||
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
|
||||
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
|
||||
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
|
||||
|
||||
## Main Packages
|
||||
|
||||
The sections above describe the architecture in concepts. If you want to connect
|
||||
those concepts to implementation details, use this table as the code map before
|
||||
reading the deeper flows below.
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
|
||||
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
|
||||
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
|
||||
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
|
||||
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
|
||||
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
|
||||
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
|
||||
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
|
||||
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
|
||||
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
|
||||
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
|
||||
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
|
||||
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
|
||||
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
|
||||
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
|
||||
|
||||
## Startup Flow
|
||||
|
||||
### Interactive TUI (`zero`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as cmd/zero
|
||||
participant CLI as internal/cli
|
||||
participant Config as internal/config
|
||||
participant TUI as internal/tui
|
||||
participant Agent as internal/agent
|
||||
|
||||
Main->>CLI: Run(args)
|
||||
CLI->>Config: resolve config for workspace
|
||||
CLI->>CLI: build provider
|
||||
CLI->>CLI: build tool registry
|
||||
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
|
||||
CLI->>CLI: create sandbox engine + session store
|
||||
CLI->>TUI: run Bubble Tea with Options
|
||||
TUI->>TUI: Init / Update / View event loop
|
||||
TUI->>Agent: agent.Run on submitted prompt
|
||||
```
|
||||
|
||||
The TUI owns terminal interaction. It tracks composer state, transcript rows,
|
||||
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
|
||||
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
|
||||
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
|
||||
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
|
||||
transcript rows and session events.
|
||||
|
||||
### Headless exec (`zero exec`)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Parse exec flags] --> B[Resolve config + provider]
|
||||
B --> C[Build tool registry]
|
||||
C --> D[Resolve prompt / stream-json input / images]
|
||||
D --> E[Prepare or resume session]
|
||||
E --> F[Create stream-json/text/json writer]
|
||||
F --> G[agent.Run]
|
||||
G --> H[Write final output and exit code]
|
||||
```
|
||||
|
||||
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
|
||||
terminal state. It can persist sessions, resume/fork existing sessions, run in
|
||||
stream-JSON mode for automation, and always requires a stronger completion
|
||||
signal so a no-tool assistant response is not treated as success while work is
|
||||
still pending.
|
||||
|
||||
## Agent Loop
|
||||
|
||||
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
|
||||
this cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
|
||||
Expose --> Compact[Maybe compact context]
|
||||
Compact --> Request[Send CompletionRequest to provider]
|
||||
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
|
||||
|
||||
Stream --> HasTools{Tool calls?}
|
||||
HasTools -- No --> FinalCheck{Final answer acceptable?}
|
||||
FinalCheck -- Yes --> Done[Return Result]
|
||||
FinalCheck -- No --> Nudge[Append user nudge and continue]
|
||||
|
||||
HasTools -- Yes --> Execute[Execute tool calls]
|
||||
Execute --> Permission[Permission / sandbox / hooks]
|
||||
Permission --> ToolResult[Append tool result messages]
|
||||
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
|
||||
Diagnostics --> Expose
|
||||
```
|
||||
|
||||
Key responsibilities of the loop:
|
||||
|
||||
1. **Build prompt state** — combines the system prompt, project/user guidelines,
|
||||
loaded skills/specialist instructions, the current user prompt, and images. The
|
||||
resulting message list is the model-facing context that later tool results are
|
||||
appended to. Resumed session history is replayed by the caller before a new run,
|
||||
not passed as a separate `agent.Run` history field.
|
||||
2. **Expose tools** — starts from the registry built by the CLI, then filters
|
||||
tool definitions by permission mode, enabled or disabled filters, and
|
||||
deferred-loading rules. Tool schemas also count toward context budget, so they
|
||||
matter for compaction decisions.
|
||||
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
|
||||
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
|
||||
the caller via callbacks.
|
||||
5. **Execute tools** — decodes tool arguments, checks filters, applies
|
||||
permissions and sandbox policy, dispatches hooks, runs the registered tool,
|
||||
redacts output, and appends a tool message. See
|
||||
[Tool Execution Lifecycle](#tool-execution-lifecycle).
|
||||
6. **Recover and guard** — handles malformed tool calls, repeated failures,
|
||||
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
|
||||
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
|
||||
|
||||
The model never directly mutates the workspace. It asks for tool calls; Zero
|
||||
validates and executes those calls through the registry, permission policy, and
|
||||
sandbox engine.
|
||||
|
||||
## Tool Execution Lifecycle
|
||||
|
||||
A tool call is part of the model conversation, not a side channel that bypasses
|
||||
it. The model proposes an action, Zero decides whether that action is allowed to
|
||||
run, the tool returns a structured result, and that result becomes context for the
|
||||
next provider request.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Model as LLM / provider
|
||||
participant Agent as agent.Run
|
||||
participant Policy as filters + permissions + sandbox
|
||||
participant Hooks as hooks
|
||||
participant Tool as tool implementation
|
||||
participant Surface as TUI / exec callbacks
|
||||
participant Transcript as model conversation + session events
|
||||
|
||||
Agent->>Model: request with messages + available tool schemas
|
||||
Model-->>Agent: assistant text and/or tool_call(name, args)
|
||||
Agent-->>Surface: stream text / tool-call preview
|
||||
Agent->>Agent: collect and decode tool arguments
|
||||
Agent->>Policy: check tool filters, permission mode, sandbox policy
|
||||
|
||||
alt denied before execution
|
||||
Policy-->>Agent: denied result
|
||||
Agent-->>Surface: tool failed/denied event
|
||||
Agent->>Transcript: append tool result explaining denial
|
||||
else needs user approval
|
||||
Policy-->>Surface: permission request
|
||||
Surface-->>Policy: allow / deny / remember decision
|
||||
Policy-->>Agent: decision
|
||||
end
|
||||
|
||||
alt approved to execute
|
||||
Agent->>Hooks: beforeTool hooks
|
||||
Hooks-->>Agent: allow / block / annotate
|
||||
Agent->>Tool: execute(ctx, args)
|
||||
Tool-->>Agent: output, error, metadata
|
||||
Agent->>Hooks: afterTool hooks
|
||||
Agent->>Agent: redact, truncate, classify success/failure
|
||||
Agent-->>Surface: OnToolResult callback
|
||||
Agent->>Transcript: append tool result message
|
||||
end
|
||||
|
||||
Agent->>Model: next request includes prior assistant tool_call + tool result
|
||||
Model-->>Agent: continue with more tools or final answer
|
||||
```
|
||||
|
||||
Conceptually, one requested tool goes through these phases:
|
||||
|
||||
1. **Proposal** — the provider streams assistant content and may emit one or more
|
||||
tool calls. Zero can show the partial tool arguments in the TUI while they are
|
||||
still streaming, but nothing has executed yet.
|
||||
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
|
||||
tool is enabled, applies permission mode, asks the sandbox engine about path,
|
||||
network, shell, or command-prefix constraints, and may invoke permission
|
||||
callbacks.
|
||||
3. **Execute** — the selected registry entry runs locally, through MCP, as a
|
||||
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
|
||||
before or after this step.
|
||||
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
|
||||
success, failure, denial, cancellation, timeout, or validation error with
|
||||
output/metadata that may be redacted or truncated.
|
||||
5. **Append to context** — the result is appended as a tool-result message paired
|
||||
with the assistant tool call. The next model request includes that result, so
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
an error such as “old string not found”; a denied shell command tells the model
|
||||
that the action is not currently permitted. The model is expected to use that
|
||||
feedback to continue, choose another tool, ask the user, or explain the blocker.
|
||||
|
||||
The judgement that “information is sufficient” is made by the model, not by the
|
||||
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
|
||||
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
|
||||
hard limits. But the semantic decision to call another tool versus produce a
|
||||
final answer comes from the next provider response. In other words:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Result[Tool result appended to conversation] --> Next[Next provider request]
|
||||
Next --> Decide{Model response}
|
||||
Decide -- More information/action needed --> MoreTools[Request another tool]
|
||||
MoreTools --> Result
|
||||
Decide -- User input needed --> AskUser[Call ask_user or explain question]
|
||||
AskUser --> Result
|
||||
Decide -- Enough information --> Final[Final assistant answer]
|
||||
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
|
||||
Guard --> Next
|
||||
```
|
||||
|
||||
This is why tool output is usually more than UI decoration. It is part of the
|
||||
working memory for the next model turn, subject to context-window pressure and
|
||||
compaction. The TUI may summarize or prettify it for the user, but the agent loop
|
||||
also stores the machine-facing result that the model sees.
|
||||
|
||||
## Context Budget and Compaction
|
||||
|
||||
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
|
||||
pressure valve. It is not a separate memory engine; it rewrites the current
|
||||
model-facing conversation so the next provider request fits within the configured
|
||||
context window.
|
||||
|
||||
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
|
||||
is enabled, the loop does this near the top of each turn:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
|
||||
Estimate --> Under{Under threshold?}
|
||||
Under -- Yes --> Keep[Keep context as-is]
|
||||
Under -- No --> Grown{Grown past low-water mark?}
|
||||
Grown -- No --> Keep
|
||||
Grown -- Yes --> Prune[Prune stale large tool outputs]
|
||||
Prune --> Recheck{Back under threshold?}
|
||||
Recheck -- Yes --> KeepPruned[Use pruned context]
|
||||
Recheck -- No --> Summarize[Summarize oldest middle]
|
||||
Summarize --> Replace[system messages + summary + recent suffix]
|
||||
```
|
||||
|
||||
The trigger is intentionally approximate:
|
||||
|
||||
- Zero estimates request size using message text, images, tool-call arguments, and
|
||||
the tool definitions advertised on that turn.
|
||||
- The proactive threshold is roughly **two-thirds of the context window**.
|
||||
- Provider usage from completed turns is used to calibrate the rough estimate over
|
||||
time, so the budget gauge and compaction decision track the provider more
|
||||
closely after a few turns.
|
||||
- A low-water mark prevents the loop from re-summarizing the same already-compacted
|
||||
history on every turn.
|
||||
|
||||
When compaction actually runs, it tries to preserve the parts most likely to be
|
||||
needed by the model:
|
||||
|
||||
- leading system messages stay verbatim;
|
||||
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
|
||||
agent's normal preserve count);
|
||||
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
|
||||
messages are not replayed without their corresponding assistant tool call;
|
||||
- the older middle is summarized into one user-role message labeled
|
||||
`[Summary of earlier conversation]`;
|
||||
- structured state that should not be paraphrased away, such as active plan/loaded
|
||||
skill state, is preserved alongside the prose summary.
|
||||
|
||||
There are two compaction paths:
|
||||
|
||||
1. **Proactive compaction** runs before the provider call when the estimated
|
||||
request is over threshold.
|
||||
2. **Reactive compaction** runs after a provider returns a context-limit style
|
||||
error. Zero compacts once and retries that same turn; if the provider still
|
||||
rejects the request, the error is surfaced rather than looping forever.
|
||||
|
||||
Compaction failures are conservative. If the summarizer fails during proactive
|
||||
compaction, Zero keeps the original messages and tries to continue. It does not
|
||||
drop conversation history just to make room.
|
||||
|
||||
## Presentation Layer: Bubble Tea TUI
|
||||
|
||||
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Init[model.Init] --> Update[model.Update]
|
||||
Update --> View[model.View]
|
||||
View --> Terminal[Terminal frame]
|
||||
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
|
||||
Msg --> Update
|
||||
```
|
||||
|
||||
The TUI is intentionally not the agent brain. It is responsible for:
|
||||
|
||||
- collecting user input from the composer;
|
||||
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
|
||||
files, and sidebars;
|
||||
- handling slash commands and modal surfaces;
|
||||
- converting agent callbacks into Bubble Tea messages;
|
||||
- persisting visible session events;
|
||||
- allowing live provider/model/permission changes.
|
||||
|
||||
When a prompt is submitted, the TUI starts a background command that invokes
|
||||
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
|
||||
loop, so rendering remains responsive while the agent is working.
|
||||
|
||||
## How the TUI and Agent Loop Connect
|
||||
|
||||
A common source of confusion is that Zero has two event loops running at once:
|
||||
|
||||
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
|
||||
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
|
||||
stop conditions.
|
||||
|
||||
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
|
||||
runs provider/tool logic directly inside `Update`; instead, submit handling gates
|
||||
and dispatches a prompt, the TUI starts a background command, and that command
|
||||
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
|
||||
program. Function names in this section are illustrative code anchors, not API
|
||||
boundaries.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant TUI as Bubble Tea model<br/>internal/tui
|
||||
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
|
||||
participant Agent as agent.Run<br/>internal/agent
|
||||
participant Provider as Provider.StreamCompletion
|
||||
participant Tools as Tool registry + sandbox
|
||||
participant Store as Session store
|
||||
|
||||
User->>TUI: type prompt / press Enter
|
||||
TUI->>TUI: validate/gate submit and append visible user row
|
||||
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
|
||||
Cmd->>Agent: agent.Run(prompt, provider, options)
|
||||
Agent->>Provider: stream completion request
|
||||
Provider-->>Agent: text / reasoning / tool-call deltas
|
||||
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
|
||||
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
|
||||
TUI->>TUI: Update appends/updates transcript rows
|
||||
TUI-->>User: View renders new frame
|
||||
|
||||
Agent->>Tools: execute collected tool call
|
||||
Tools->>Tools: permission, sandbox, hooks, redaction
|
||||
Tools-->>Agent: tool result
|
||||
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
|
||||
Cmd->>Store: batch session events for resume/history
|
||||
Cmd-->>TUI: final agentResponseMsg
|
||||
TUI->>TUI: mark run complete, flush rows/events, unlock composer
|
||||
```
|
||||
|
||||
In the TUI path, the same event can have three representations:
|
||||
|
||||
| Runtime thing | Agent representation | TUI representation | Session representation |
|
||||
| --- | --- | --- | --- |
|
||||
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
|
||||
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
|
||||
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
|
||||
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
|
||||
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
|
||||
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
|
||||
|
||||
### One interactive turn, end to end
|
||||
|
||||
For a normal prompt in the interactive TUI:
|
||||
|
||||
1. Submit handling validates composer state and command gates, then dispatches
|
||||
the prompt to the launch path.
|
||||
2. The launch path appends immediate UI rows, creates a cancellable run context,
|
||||
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
|
||||
to correlate later events.
|
||||
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
|
||||
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
|
||||
images, registry, response style, context window, and self-correction hooks.
|
||||
4. The command wraps agent callbacks. For example:
|
||||
- `OnText` sends incremental assistant text into the TUI message sink.
|
||||
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
|
||||
- `OnToolCall` creates visible tool rows and appends session events.
|
||||
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
|
||||
records changed files.
|
||||
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
|
||||
until the user answers or the run context is cancelled.
|
||||
- `OnAskUser` does the same for model-initiated clarification questions.
|
||||
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
|
||||
provider response, executes any requested tools through the registry/sandbox,
|
||||
appends tool results back into the model conversation, and repeats until it has
|
||||
a final answer or a stop condition.
|
||||
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
|
||||
loop marks the run complete, flushes queued rows/session events, updates usage
|
||||
and file state, and re-enables the composer.
|
||||
|
||||
The important architectural point: **the TUI is stateful and reactive, but the
|
||||
agent loop is the source of runtime truth for model/tool progress**. Presentation
|
||||
state is derived from callback events and persisted session events; workspace
|
||||
changes happen only through tools executed by the agent runtime.
|
||||
|
||||
### Feature flows use the same bridge
|
||||
|
||||
For one interactive turn, the supporting systems do not run beside the TUI/agent
|
||||
loop; they run inside or around it:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Submit[TUI submit] --> Options[Build agent.Options]
|
||||
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
|
||||
Context --> ProviderCall[Provider stream]
|
||||
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
|
||||
StreamEvents --> Render[TUI render callbacks]
|
||||
|
||||
ProviderCall --> ToolCall{Tool call?}
|
||||
ToolCall -- No --> Final[Final response]
|
||||
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
|
||||
ToolGate --> HooksBefore[beforeTool hooks]
|
||||
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
|
||||
Execute --> HooksAfter[afterTool hooks]
|
||||
HooksAfter --> ToolResult[Tool result message]
|
||||
ToolResult --> Persist[Session events]
|
||||
ToolResult --> Context
|
||||
|
||||
Final --> Persist
|
||||
Persist --> Resume[Resume / fork / rewind / parent metadata]
|
||||
```
|
||||
|
||||
Most interactive features are variations of that same bridge rather than separate
|
||||
architectures:
|
||||
|
||||
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
|
||||
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
|
||||
(`/doctor`, `/search`, `/compact`, session commands).
|
||||
- **Permissions** sit between a model-requested tool call and actual execution:
|
||||
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
|
||||
the decision back through a channel, and the session log records the
|
||||
request/decision events.
|
||||
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
|
||||
runtime pattern: the model requests a capability, the agent records the
|
||||
call/result in its conversation, and the TUI mirrors the visible state as plan
|
||||
panels, specialist cards, or question forms. Specialist session metadata can
|
||||
carry parent/root and sub-agent/spec fields, with richer linkage depending on
|
||||
the specialist or swarm path.
|
||||
- **Streaming tool writes** start as provider tool-call deltas before execution;
|
||||
the TUI shows the arguments live, then replaces/augments them with the actual
|
||||
`ToolResult` after the tool runs.
|
||||
- **Compaction** is an agent-loop context-management step; the TUI may show that
|
||||
it happened, but the purpose is to keep the next provider request within budget.
|
||||
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
|
||||
serializes the events as JSON protocol events instead of Bubble Tea views.
|
||||
|
||||
So a feature-specific deep dive should usually explain: what user event starts it,
|
||||
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
|
||||
which session events make it resumable.
|
||||
|
||||
## Runtime Provider Layer
|
||||
|
||||
Providers are hidden behind this interface in `internal/zeroruntime`:
|
||||
|
||||
```go
|
||||
type Provider interface {
|
||||
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
|
||||
}
|
||||
```
|
||||
|
||||
The agent loop only knows about provider-neutral messages, tool definitions,
|
||||
stream events, reasoning blocks, images, and token usage. Provider adapters in
|
||||
`internal/providers/*` translate those neutral types to provider-specific APIs:
|
||||
|
||||
- OpenAI and OpenAI-compatible APIs;
|
||||
- Anthropic and Anthropic-compatible APIs;
|
||||
- Gemini;
|
||||
- Codex / ChatGPT OAuth-backed runtime.
|
||||
|
||||
`internal/providers.New` resolves the configured model through the model
|
||||
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
|
||||
OAuth resolvers, and returns the concrete adapter.
|
||||
|
||||
## Tools and Capabilities
|
||||
|
||||
Tools implement a common interface and are registered by name in
|
||||
`internal/tools.Registry`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
|
||||
Decode --> Filter[Enabled/disabled + permission-mode filter]
|
||||
Filter --> Precheck[Pre-permission rejecters]
|
||||
Precheck --> SandboxEval[Sandbox Evaluate]
|
||||
SandboxEval --> Prompt{Needs user approval?}
|
||||
Prompt -- Yes --> Decision[Permission callback]
|
||||
Prompt -- No --> Run[Registry.RunWithOptions]
|
||||
Decision --> Run
|
||||
Run --> Redact[Redact secrets + enforce output ceiling]
|
||||
Redact --> Message[Return tool result to model]
|
||||
```
|
||||
|
||||
Core tool groups include:
|
||||
|
||||
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
|
||||
skills, `ask_user`, permission requests.
|
||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||
- **Network tools**: `web_fetch` and optional web search backend.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
|
||||
|
||||
Tool definitions include safety metadata: side-effect class, permission level,
|
||||
reason, and whether prompt-gated tools are advertised in auto-like modes. The
|
||||
agent uses this metadata plus per-turn filters and deferred-loading state to
|
||||
choose which registered tools the model can see and whether a call can run
|
||||
immediately.
|
||||
|
||||
## Permission and Sandbox Model
|
||||
|
||||
Zero has two related but separate checks:
|
||||
|
||||
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
|
||||
approval.
|
||||
2. **Sandbox policy** evaluates the actual request: path scope, network access,
|
||||
destructive shell patterns, explicit escalation, persistent/session grants,
|
||||
and platform isolation availability.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Call[Tool call] --> Safety[Tool Safety metadata]
|
||||
Call --> Scope[Workspace/additional roots scope]
|
||||
Safety --> Engine[Sandbox Engine Evaluate]
|
||||
Scope --> Engine
|
||||
Engine --> Allow[Allow]
|
||||
Engine --> Prompt[Prompt user]
|
||||
Engine --> Deny[Deny]
|
||||
Prompt --> Grant[Turn/session/persistent grant]
|
||||
Grant --> Execute[Execute tool]
|
||||
Allow --> Execute
|
||||
Deny --> Result[Return denied tool result]
|
||||
```
|
||||
|
||||
The sandbox engine can:
|
||||
|
||||
- auto-allow workspace writes for safe file-edit tools;
|
||||
- block reads/writes outside the allowed scope;
|
||||
- prompt for recoverable outside-workspace access;
|
||||
- deny or prompt for network depending on policy;
|
||||
- detect destructive shell commands;
|
||||
- run shell commands through a platform backend when available;
|
||||
- remember session or persistent grants;
|
||||
- support narrow command-prefix approvals.
|
||||
|
||||
The user-facing TUI and `zero exec` mode both route permission requests through
|
||||
callbacks, but the TUI can show interactive modals while headless mode generally
|
||||
returns non-interactive fallback behavior unless a protocol/client handles it.
|
||||
|
||||
## Session Storage and History
|
||||
|
||||
Sessions live locally under Zero's data directory. A session directory contains:
|
||||
|
||||
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
|
||||
spec/sub-agent fields, timestamps, and event count.
|
||||
- `events.jsonl` — append-only event log with messages, tool calls/results,
|
||||
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
|
||||
the richer event stream; ACP currently records conversational message history
|
||||
while streaming tool and permission updates to the client.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TUI[TUI callbacks] --> Store[internal/sessions.Store]
|
||||
Exec[exec recorder] --> Store
|
||||
Store --> Metadata[metadata.json]
|
||||
Store --> Events[events.jsonl]
|
||||
Events --> Resume[resume]
|
||||
Events --> Fork[fork]
|
||||
Events --> Rewind[rewind/checkpoints]
|
||||
Events --> Children[parent/root metadata]
|
||||
```
|
||||
|
||||
This is why sessions can be resumed, forked, rewound, and inspected without a
|
||||
remote service. Zero stores the local event history; providers only receive the
|
||||
messages needed for the current completion request.
|
||||
|
||||
## Stream-JSON Protocol
|
||||
|
||||
`zero exec --output-format stream-json` emits newline-delimited JSON events for
|
||||
integrations and CI. The protocol is implemented in `internal/streamjson` and is
|
||||
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
Common output event types include:
|
||||
|
||||
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
|
||||
- `permission`, `permission_request`, `permission_decision`;
|
||||
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
|
||||
|
||||
Input stream-JSON can provide `prompt` or `message` events and image payloads.
|
||||
The CLI parses those events, resolves a single prompt, validates/decodes images,
|
||||
and passes the result into the same agent loop used everywhere else.
|
||||
|
||||
## Extensibility Flow
|
||||
|
||||
Zero's extension surfaces are loaded before the agent loop starts:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Config[Config resolution] --> Registry[Tool registry]
|
||||
Registry --> Core[Core tools]
|
||||
Registry --> MCP[MCP server tools]
|
||||
Registry --> Specialist[Specialist / Task tools]
|
||||
Registry --> Plugin[Plugin tools]
|
||||
|
||||
Config --> Prompt[System prompt context]
|
||||
Prompt --> Guidelines[AGENTS.md / ZERO.md]
|
||||
Prompt --> Skills[Skills list]
|
||||
Prompt --> SpecialistContext[Specialist descriptions]
|
||||
|
||||
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
|
||||
```
|
||||
|
||||
- **Project/user guidelines** become prompt context.
|
||||
- **Skills** are instruction packs the model can load on demand.
|
||||
- **Specialists** are sub-agents callable through the `Task` tool.
|
||||
- **MCP servers** contribute external tools.
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap merges plugin skill
|
||||
roots into the runtime skill tool and prompt-visible skill list; the base
|
||||
`internal/skills` scanner still scans one root at a time.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
A typical interactive edit request looks like this:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant T as TUI
|
||||
participant A as Agent loop
|
||||
participant P as Provider
|
||||
participant R as Tool registry
|
||||
participant S as Sandbox
|
||||
participant FS as Workspace/session files
|
||||
|
||||
U->>T: Type prompt and press Enter
|
||||
T->>A: agent.Run(prompt, provider, options)
|
||||
A->>P: CompletionRequest(messages, tool definitions)
|
||||
P-->>A: Stream text + tool call edit_file
|
||||
A->>S: Evaluate edit_file args
|
||||
S-->>A: allow/prompt/deny decision
|
||||
A->>T: permission callback if prompt needed
|
||||
T-->>A: user decision
|
||||
A->>R: Run edit_file
|
||||
R->>FS: Write workspace file
|
||||
R-->>A: Tool result + changed files
|
||||
A->>P: Next CompletionRequest with tool result
|
||||
P-->>A: Final answer
|
||||
A-->>T: Result + callbacks
|
||||
T->>FS: Append session events
|
||||
T-->>U: Render final transcript
|
||||
```
|
||||
|
||||
The same request in `zero exec` follows the same provider/tool/sandbox path, but
|
||||
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
|
||||
|
||||
## Mental Model
|
||||
|
||||
When you work on Zero, start by locating the change on the four-layer spine from
|
||||
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
|
||||
Persistence — then jump to the package that owns that boundary:
|
||||
|
||||
- UI behavior? Start in `internal/tui`.
|
||||
- Command-line behavior? Start in `internal/cli`.
|
||||
- Model/tool-turn behavior? Start in `internal/agent`.
|
||||
- A new capability? Register a tool in `internal/tools` or an extension surface.
|
||||
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
|
||||
- Resume/history behavior? Start in `internal/sessions` and session recorders.
|
||||
+48
-21
@@ -6,9 +6,8 @@ Zero is distributed as:
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.25+
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -17,32 +16,59 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The package supports Linux and macOS on x64 and arm64, and Windows on x64
|
||||
(Windows on ARM runs the x64 build under emulation). It installs a small
|
||||
`zero` wrapper plus, as an optional dependency, a platform payload with the
|
||||
native binary and the bundled browser/terminal control helpers. There are no
|
||||
install scripts and nothing is downloaded from outside the npm registry, so
|
||||
the install is silent and works identically under npm, Bun, pnpm, and yarn —
|
||||
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
|
||||
package architecture.
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
If the install skipped optional dependencies (`npm install --omit=optional`,
|
||||
or a package manager configured to do so), the wrapper falls back to
|
||||
downloading the binary from the matching GitHub Release, with checksum
|
||||
verification. The fetch is retried on each run until a binary is in place (a
|
||||
transient network failure heals itself; an install directory the current user
|
||||
cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
@@ -226,7 +252,8 @@ Supported targets:
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64` (Windows on ARM runs this build under emulation)
|
||||
- `windows-x64`
|
||||
- `windows-arm64`
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# npm packaging
|
||||
|
||||
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
|
||||
`release-artifacts.yml`.
|
||||
|
||||
## Goals
|
||||
|
||||
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,
|
||||
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
|
||||
install zero without prompts, trust ceremonies, or broken binaries.
|
||||
- No network fetches outside the npm registry at install time. GitHub being
|
||||
down or rate-limited must not break `npm install`.
|
||||
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
|
||||
of the box.
|
||||
|
||||
## Architecture
|
||||
|
||||
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@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 `@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.
|
||||
The alias suffixes use Node's `process.platform`/`process.arch` names so
|
||||
`bin/zero.js` can derive its platform package directly.
|
||||
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
|
||||
them and skips the rest.
|
||||
- A platform payload is assembled from the platform's release archive by
|
||||
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
|
||||
the platform's sandbox helpers, and the vendored `helpers/` tree (see
|
||||
below). It has no `bin`, no scripts, and no dependencies — it is inert on
|
||||
its own; the wrapper execs the binary out of it.
|
||||
- The published wrapper has **no scripts and no dependencies** — the
|
||||
assembly script strips both from the repo `package.json` and injects the
|
||||
exact-version `optionalDependencies`. (The repo `package.json` keeps
|
||||
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
|
||||
the vendored helpers tree; they are never installed by consumers.)
|
||||
- There is **no windows-arm64 build** (matches the release matrix); Windows on
|
||||
ARM runs the x64 build under emulation via the first-run fallback.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
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.
|
||||
3. If neither exists (`--omit=optional`, package managers that skip optional
|
||||
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
|
||||
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
|
||||
verified against the release's own checksum file, no zip-slip), invoked by
|
||||
the wrapper itself. Failures are deliberately not cached — the fetch
|
||||
retries on every run until a binary is in place, so a transient network
|
||||
error self-heals. Platforms with no release asset (anything outside the
|
||||
matrix above, including windows-arm64) skip the attempt entirely rather
|
||||
than probing the network each run.
|
||||
4. If the download is impossible too, print build-from-source guidance.
|
||||
|
||||
There is deliberately **no `scripts.postinstall`** in any published
|
||||
`package.json`. The downloader exists only as a first-run fallback.
|
||||
|
||||
### Vendored helpers (`helpers/`)
|
||||
|
||||
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
|
||||
**vendored binaries/packages inside the platform payload**, not npm
|
||||
dependencies of the wrapper:
|
||||
|
||||
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
|
||||
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
|
||||
every installer. As a vendored tree it produces none, because npm never
|
||||
resolves it.
|
||||
- `zero-release package` already stages the helpers tree into every release
|
||||
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
|
||||
`package.json` pins + lockfile on the native builder). The assembly script
|
||||
reuses that staged tree, so npm installs and `install.sh` installs get
|
||||
identical helpers.
|
||||
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
|
||||
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
|
||||
wrapper involvement, no configuration.
|
||||
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
|
||||
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
|
||||
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
|
||||
other symlink, so the vendored tree survives publishing. (Verified
|
||||
empirically; nested `node_modules` under `helpers/` IS packed — only the
|
||||
package-root `node_modules` is always ignored.)
|
||||
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
|
||||
platform (7 total). The assembly script keeps only the payload's own
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
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
|
||||
non-`latest` dist-tag would clobber `latest` and users would install a
|
||||
platform payload as the CLI. The workflow publishes platform versions with
|
||||
`--tag platform` and asserts `latest` survived before the wrapper publish,
|
||||
then asserts `latest` equals the wrapper version after it. Both lookups
|
||||
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
|
||||
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 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.
|
||||
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.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`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
|
||||
|
||||
The vendored helper versions are pinned by the repo `package.json`
|
||||
`dependencies` + `package-lock.json`; the release build vendors exactly what
|
||||
the lockfile resolves. No `^range` surprises ship: a release carries what its
|
||||
lockfile pinned at build time.
|
||||
|
||||
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
|
||||
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
|
||||
ship with the next release.
|
||||
- Security response: if a vendored helper publishes a security fix, the bump
|
||||
is release-worthy on its own — cut a patch release of zero carrying only the
|
||||
pin change.
|
||||
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
|
||||
vendored package directory (`helpers/node_modules/agent-browser/`), which
|
||||
satisfies redistribution attribution.
|
||||
|
||||
## History / rationale
|
||||
|
||||
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
|
||||
`agent-browser`/`tuistory` as regular dependencies. That produced three
|
||||
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
|
||||
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
|
||||
silently under Bun's default script blocking, and coupled `npm install` to
|
||||
GitHub Releases availability. The platform-package model removes the warnings
|
||||
structurally — there is nothing left in the tree for a package manager to warn
|
||||
about — rather than asking users to approve or suppress them.
|
||||
+10
-45
@@ -1,8 +1,8 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
|
||||
wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -21,52 +21,17 @@ go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
|
||||
## End-To-End Assembly Smoke
|
||||
|
||||
Build a real archive for the host platform and assemble the npm payloads from
|
||||
it (this is exactly what the publish job does):
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
|
||||
```
|
||||
|
||||
Then simulate an installed layout and run the wrapper through each resolution
|
||||
path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
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/@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/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `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 `@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`
|
||||
matrix.
|
||||
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
|
||||
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
|
||||
plan), verifies the downloaded archive's SHA-256, and extracts only the
|
||||
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
|
||||
(exit 0) and an unsupported platform/arch is a non-fatal skip.
|
||||
- The wrapper prefers the platform package binary over a previously downloaded
|
||||
one, and `agent-browser`/`tuistory` run from the platform package's
|
||||
`helpers/node_modules/.bin` shims.
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
|
||||
@@ -17,7 +17,6 @@ Zero.
|
||||
|
||||
## Maintainer Docs
|
||||
|
||||
- [How Zero Works](HOW_ZERO_WORKS.md)
|
||||
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
|
||||
- [Benchmarks](BENCHMARK.md)
|
||||
- [Performance benchmarks](PERFORMANCE.md)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
@@ -12,7 +14,6 @@ require (
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
|
||||
@@ -32,8 +32,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
|
||||
+21
-54
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -160,14 +159,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
history := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
|
||||
if a.deps.Store != nil {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
})
|
||||
if assistant != "" {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
sessionID := ""
|
||||
if note != nil {
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -244,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO") &&
|
||||
strings.Contains(text, "Could not save session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not save session history") {
|
||||
t.Fatalf("streamed text = %q, want persistence warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
|
||||
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt events: %v", err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Could not load session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not load session history") {
|
||||
t.Fatalf("streamed text = %q, want load warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
return drainTextUntil(t, ch, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO")
|
||||
})
|
||||
}
|
||||
|
||||
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if done(b.String()) {
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
|
||||
@@ -72,26 +72,6 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
// real false positive (conversational recap): retelling a PAST exchange is
|
||||
// not an admission about the current objective — the sentence carries its
|
||||
// own narrative context ("You asked …", "at the time"):
|
||||
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
|
||||
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
|
||||
{"Last session I was unable to build the project; that is what we should retry now.", false},
|
||||
// quoted admissions (the model citing its own earlier message or a log
|
||||
// line) must NOT be flagged:
|
||||
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
|
||||
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
|
||||
// current-objective admissions must STILL be flagged, including past tense
|
||||
// and multi-sentence finals whose other sentences are narrative:
|
||||
{"I couldn't complete the refactor.", true},
|
||||
{"I hit an error I could not resolve, so the migration is unfinished.", true},
|
||||
{"You asked me to port the parser. I couldn't finish the second half.", true},
|
||||
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
|
||||
// spans are stripped), so an admission after a stray quote still fires:
|
||||
{"The log ends with a stray \" and I couldn't finish the migration.", true},
|
||||
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
|
||||
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
|
||||
@@ -183,112 +183,35 @@ var successNegationTails = []string{
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
// reporting the outcome of the current objective, so an inability admission in
|
||||
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
|
||||
// conversational recap ("You asked if I could work autonomously … I was honest
|
||||
// that my sandbox had no repo … so I couldn't actually do it at the time") was
|
||||
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
|
||||
// current turn's objective (summarize where we left off) was fully met. Every
|
||||
// marker is second-person or explicitly past-referential, so a first-person
|
||||
// admission about the current task ("I couldn't complete the refactor") is
|
||||
// never masked.
|
||||
var narrativeMarkers = []string{
|
||||
"you asked", "you said", "you wanted", "you mentioned",
|
||||
"we discussed", "we talked", "we covered",
|
||||
"when we last", "last time", "last session", "previous session",
|
||||
"previous conversation", "earlier session", "earlier conversation",
|
||||
"at the time", "back then",
|
||||
}
|
||||
|
||||
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
|
||||
// backticks, so an admission the model merely QUOTES — its own earlier message,
|
||||
// a log line, an error string — cannot fire the detector. Only BALANCED spans
|
||||
// are removed: an opening delimiter that never closes is kept as literal text,
|
||||
// so a stray quote cannot swallow the rest of the message (and with it a
|
||||
// genuine admission the detector must see). Single quotes are left alone: they
|
||||
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
|
||||
// delimiters would swallow the text between two contractions.
|
||||
func stripQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
var span strings.Builder // pending text since the open delimiter, kept if it never closes
|
||||
open := rune(0)
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case open != 0:
|
||||
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
|
||||
open = 0
|
||||
span.Reset()
|
||||
continue
|
||||
}
|
||||
span.WriteRune(r)
|
||||
case r == '"' || r == '“' || r == '`':
|
||||
open = r
|
||||
span.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// admissionSentences splits lowered text into sentence-ish fragments so the
|
||||
// detector judges each claim in its own context. Newlines split too (markdown
|
||||
// bullets are separate claims); the exact boundaries only need to keep an
|
||||
// admission next to its own narrative/negation context, not be grammatical.
|
||||
func admissionSentences(lower string) []string {
|
||||
return strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?' || r == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
// Matching is per sentence, after dropping quoted spans, and a sentence that
|
||||
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
|
||||
// must be the model's own report about the CURRENT objective, not general
|
||||
// language that merely resembles one.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(sentence[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
|
||||
+4
-58
@@ -109,7 +109,7 @@ const escalationFailedNoticePrefix = "Note: could not switch to the requested mo
|
||||
// The system prompt (core coding-craft instructions + workspace context + safety
|
||||
// confirmation policy) is assembled in system_prompt.go via buildSystemPrompt.
|
||||
|
||||
func Run(ctx context.Context, prompt string, provider Provider, options Options) (result Result, err error) {
|
||||
func Run(ctx context.Context, prompt string, provider Provider, options Options) (Result, error) {
|
||||
if provider == nil {
|
||||
return Result{}, errors.New("agent provider is required")
|
||||
}
|
||||
@@ -161,11 +161,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||||
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||||
|
||||
result = Result{Messages: copyMessages(messages)}
|
||||
dispatchSessionStart(ctx, options)
|
||||
defer func() {
|
||||
dispatchSessionEnd(ctx, options, result, err)
|
||||
}()
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
|
||||
@@ -725,8 +721,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -746,7 +741,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -757,7 +752,6 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
@@ -1574,54 +1568,6 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
|
||||
return strings.TrimSpace(strings.Join(outcome.Messages, "\n"))
|
||||
}
|
||||
|
||||
// dispatchSessionStart runs configured sessionStart hooks once before the first
|
||||
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
|
||||
// never block the run.
|
||||
func dispatchSessionStart(ctx context.Context, options Options) {
|
||||
if options.Hooks == nil {
|
||||
return
|
||||
}
|
||||
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
Event: hooks.EventSessionStart,
|
||||
Payload: map[string]any{
|
||||
"event": string(hooks.EventSessionStart),
|
||||
"sessionId": options.SessionID,
|
||||
"cwd": options.Cwd,
|
||||
"provider": options.ProviderName,
|
||||
"model": options.Model,
|
||||
"depth": options.Depth,
|
||||
"tag": options.Tag,
|
||||
"sessionTitle": options.SessionTitle,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
|
||||
// exits, including early error returns. Lifecycle hooks are advisory.
|
||||
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
|
||||
if options.Hooks == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"event": string(hooks.EventSessionEnd),
|
||||
"sessionId": options.SessionID,
|
||||
"cwd": options.Cwd,
|
||||
"provider": options.ProviderName,
|
||||
"model": options.Model,
|
||||
"turns": result.Turns,
|
||||
"stopReason": string(result.StopReason),
|
||||
"finishReason": result.FinishReason,
|
||||
"incomplete": result.Incomplete,
|
||||
}
|
||||
if runErr != nil {
|
||||
payload["error"] = runErr.Error()
|
||||
}
|
||||
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
|
||||
Event: hooks.EventSessionEnd,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// blockedByHookResult is the tool result for a call vetoed by a beforeTool hook.
|
||||
func blockedByHookResult(call ToolCall, outcome hooks.DispatchOutcome) ToolResult {
|
||||
// The hook reason (its stdout/stderr) is model-visible here, an intercepted
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -39,59 +38,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: "zero-missing-hook-command", Enabled: true},
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: "zero-missing-hook-command", Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
_, err = Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "session-123",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
started := map[hooks.Event]int{}
|
||||
completed := map[hooks.Event]int{}
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "hook_execution_started":
|
||||
started[event.Event]++
|
||||
case "hook_execution_completed":
|
||||
completed[event.Event]++
|
||||
}
|
||||
}
|
||||
if started[hooks.EventSessionStart] != 1 || completed[hooks.EventSessionStart] != 1 {
|
||||
t.Fatalf("sessionStart audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionStart], completed[hooks.EventSessionStart], events)
|
||||
}
|
||||
if started[hooks.EventSessionEnd] != 1 || completed[hooks.EventSessionEnd] != 1 {
|
||||
t.Fatalf("sessionEnd audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionEnd], completed[hooks.EventSessionEnd], events)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
@@ -2822,11 +2768,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
|
||||
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
finalRequest := provider.requests[1]
|
||||
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
|
||||
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
|
||||
if len(finalRequest.Tools) != 0 {
|
||||
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
|
||||
}
|
||||
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
|
||||
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
|
||||
@@ -2967,22 +2913,11 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated", "use double quotes around the value"} {
|
||||
for _, want := range []string{"Windows cmd.exe syntax", "cwd argument", "MSYS binaries", "grep", "require_escalated"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
// The examples must themselves use the safe (double-quoted) form; a
|
||||
// single-quoted example here would teach the model the exact syntax
|
||||
// that fails under cmd.exe.
|
||||
for _, want := range []string{`--jq ".a | b"`, `-run "A|B"`} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected double-quoted example %q in Windows shell guidance, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, `'.a | b'`) || strings.Contains(prompt, `'A|B'`) {
|
||||
t.Fatalf("Windows shell guidance must not show the unsafe single-quoted form, got %q", prompt)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools. To put | & > < etc inside an arg value, use double quotes around the value, not single quotes (single quotes do not protect metachars in cmd.exe): gh --jq \".a | b\", go test -run \"A|B\". Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required. Prefer the workdir/cwd argument over cd when changing directories.\n")
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories. Do not pipe to or invoke POSIX coreutils from Git for Windows (usr\\bin head/grep/tail/cat/...): they are MSYS binaries and fail under the write-restricted sandbox; use native Zero tools (grep, read_file, list_directory, glob) or cmd.exe findstr/more instead, or sandbox_permissions require_escalated only when host-level execution is truly required.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
|
||||
+17
-83
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/provideronboarding"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
@@ -51,7 +50,7 @@ type appDeps struct {
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
// exportActiveProvider pins spawned children to the run's provider (production:
|
||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||
@@ -61,7 +60,6 @@ type appDeps struct {
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
@@ -125,12 +123,11 @@ func defaultAppDeps() appDeps {
|
||||
options.Overrides = overrides
|
||||
return config.Resolve(options)
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
return config.MCPConfig{}, err
|
||||
}
|
||||
options.ExcludeProject = excludeProject
|
||||
return config.ResolveMCP(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
@@ -147,7 +144,6 @@ func defaultAppDeps() appDeps {
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
openRouterLogin: provideroauth.OpenRouterLogin,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -407,8 +403,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||
case "verify":
|
||||
return runVerifyCommand(args[1:], stdout, stderr, deps)
|
||||
case "trust":
|
||||
return runTrust(args[1:], stdout, stderr, deps)
|
||||
case "eval":
|
||||
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
|
||||
case "changes", "change":
|
||||
@@ -471,9 +465,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
if deps.openRouterLogin == nil {
|
||||
deps.openRouterLogin = defaults.openRouterLogin
|
||||
}
|
||||
if deps.newSessionStore == nil {
|
||||
deps.newSessionStore = defaults.newSessionStore
|
||||
}
|
||||
@@ -563,11 +554,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
|
||||
}
|
||||
baseProbeProviderHealth := deps.probeProviderHealth
|
||||
deps.probeProviderHealth = func(ctx context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
options.Profile = applyStoredProviderKeyAt(options.Profile, userConfigPath)
|
||||
return baseProbeProviderHealth(ctx, options)
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
@@ -672,17 +658,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
|
||||
}
|
||||
defer closeSpecialistRuntime(stderr, specialistRuntime)
|
||||
// The TUI has no --worktree reassignment, so trustRoot == workspaceRoot here.
|
||||
// Gate the project MCP layer behind the workspace-trust check (fail-closed): an
|
||||
// untrusted workspace must not spawn its ./.zero/config.json stdio MCP servers.
|
||||
// Keep the store-read error so the notice below can distinguish a fail-closed
|
||||
// store error from a clean untrusted verdict.
|
||||
mcpExcludeProject, mcpTrustErrored := resolveTrust(workspaceRoot)
|
||||
mcpSkip := trustSkip{
|
||||
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
trustCheckErrored: mcpTrustErrored,
|
||||
}
|
||||
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject)
|
||||
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
@@ -710,24 +686,15 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// A server that could not be reached or validated is skipped, not fatal (one
|
||||
// bad MCP server must not abort startup) — surface each so a missing tool set is
|
||||
// explained rather than silently absent. A built-in default the user never
|
||||
// configured (e.g. keyless Firecrawl with no credentials) is the exception: it
|
||||
// was never asked for, so its failure is not worth a startup warning — only
|
||||
// servers the user actually configured warn on failure (issue #552).
|
||||
// explained rather than silently absent.
|
||||
for _, skipped := range mcpRuntime.Skipped() {
|
||||
if skipped.UnconfiguredDefault {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stderr, "warning: MCP server %s unavailable, skipped: %s\n", skipped.Name, redaction.ErrorMessage(skipped.Err, redaction.Options{}))
|
||||
}
|
||||
// Make local plugins live: register their declared tools into the registry and
|
||||
// collect their hooks + skill roots for the dispatcher and skill tool below.
|
||||
// Done after specialist + MCP registration so plugin tools are part of the
|
||||
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
|
||||
// The interactive TUI is not worktree-reassigned, so the trust root is the
|
||||
// launch directory itself.
|
||||
trustRoot := workspaceRoot
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
@@ -762,26 +729,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Scope: scope,
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
fileTracker := tools.NewFileTracker()
|
||||
var scratchBaseline scratchFileBaseline
|
||||
sttServerManager := newDictationServerManager(resolved.STT)
|
||||
// Keep STT downloads in the SAME config tree the rest of the TUI uses. Deriving
|
||||
// from userConfigPath (rather than config.UserConfigDir()) matters when the config
|
||||
// root is overridden — e.g. in tests or a custom ZERO config dir — so the two
|
||||
// don't diverge. userConfigPath points at .../zero/config.json, so its dir is the
|
||||
// zero config dir.
|
||||
sttDownloadRoot := ""
|
||||
if userConfigPath != "" {
|
||||
sttDownloadRoot = filepath.Join(filepath.Dir(userConfigPath), "stt")
|
||||
}
|
||||
// Build the hooks dispatcher out of the AgentOptions literal so its trust skip
|
||||
// report can be combined with the plugin activation's, and emit at most one
|
||||
// notice when project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
@@ -792,24 +741,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
ProviderProfile: resolved.Provider,
|
||||
SavedProviders: usableSavedProviders(resolved.Providers),
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
RecentModels: resolved.Preferences.RecentModels,
|
||||
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
PrepareRunCompletionWarning: func() {
|
||||
scratchBaseline = scratchFileSnapshot(workspaceRoot)
|
||||
},
|
||||
RunCompletionWarning: func() string {
|
||||
return scratchFileWarning(workspaceRoot, scratchBaseline)
|
||||
},
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -817,7 +759,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
|
||||
nextConfig := lastKnownMCPConfig
|
||||
if refreshed, err := deps.resolveMCPConfig(workspaceRoot, mcpExcludeProject); err == nil {
|
||||
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
|
||||
lastKnownMCPConfig = refreshed
|
||||
nextConfig = refreshed
|
||||
}
|
||||
@@ -835,8 +777,8 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: "low",
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: fileTracker,
|
||||
Hooks: hookDispatcher,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
@@ -849,15 +791,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
|
||||
return merged
|
||||
}),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
STT: resolved.STT,
|
||||
BuildDictationTranscriber: newDictationTranscriberFactory(resolved, userConfigPath, sttServerManager),
|
||||
ShutdownDictationServer: sttServerManager.Shutdown,
|
||||
STTDownloadRoot: sttDownloadRoot,
|
||||
STTKeyStatus: newSTTKeyStatus(resolved, userConfigPath),
|
||||
SaveSTTKey: newSaveSTTKey(userConfigPath),
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -1347,8 +1283,6 @@ Flags:
|
||||
--notify <off|bell|notify|both>
|
||||
Override notification mode for this run
|
||||
--no-notify Disable notifications for this run
|
||||
--no-completion-gate Accept a no-tool-call reply as final without the INCOMPLETE
|
||||
downgrade (for conversational callers with an operator present)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
+4
-131
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -159,86 +158,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
type fakeMCPRuntimeWithSkips struct {
|
||||
skipped []mcp.SkippedServer
|
||||
}
|
||||
|
||||
func (r fakeMCPRuntimeWithSkips) Close() error { return nil }
|
||||
|
||||
func (r fakeMCPRuntimeWithSkips) Skipped() []mcp.SkippedServer { return r.skipped }
|
||||
|
||||
func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"firecrawl": config.DefaultMCPServers()["firecrawl"],
|
||||
}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
||||
{Name: "firecrawl", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
||||
}}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "firecrawl") {
|
||||
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIStartupWarnsForUserConfiguredServerSkip(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"custom": {Type: "stdio", Command: "custom-mcp"},
|
||||
}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
||||
{Name: "custom", Err: errors.New("connect failed"), UnconfiguredDefault: false},
|
||||
}}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: MCP server custom unavailable") {
|
||||
t.Fatalf("stderr = %q, want a warning for a user-configured server that failed", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -405,7 +324,7 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -480,7 +399,7 @@ func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
resolveCalls++
|
||||
switch resolveCalls {
|
||||
case 1:
|
||||
@@ -547,7 +466,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
@@ -602,7 +521,7 @@ func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 8}, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) {
|
||||
@@ -741,52 +660,6 @@ func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunInteractiveSurfacesMCPTrustNotice executes the interactive (TUI) path's MCP
|
||||
// trust notice (app.go), the third notice site alongside exec and spec-draft. An
|
||||
// untrusted repo whose only project config is MCP must print the notice before runTUI;
|
||||
// trusting it silences it. runTUI is stubbed so nothing renders; resolveMCPConfig
|
||||
// returns no servers so nothing spawns -- the notice depends only on the trust verdict
|
||||
// and the real ./.zero/config.json.
|
||||
func TestRunInteractiveSurfacesMCPTrustNotice(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := func() string {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return repo, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{MaxTurns: 3}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
runTUI: func(context.Context, tui.Options) int { return 0 },
|
||||
})
|
||||
if code != 0 {
|
||||
t.Fatalf("interactive launch exit = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
return stderr.String()
|
||||
}
|
||||
|
||||
untrusted := run()
|
||||
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
|
||||
t.Fatalf("untrusted interactive launch must surface the MCP trust notice, stderr=%q", untrusted)
|
||||
}
|
||||
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trusted := run(); strings.Contains(trusted, "ignoring project") {
|
||||
t.Fatalf("trusted interactive launch must not emit a trust notice, stderr=%q", trusted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
+9
-100
@@ -6,52 +6,15 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
|
||||
// provider: without a profile in config.json the login shows up nowhere — not in
|
||||
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
|
||||
// fails "not found", so the user's working active provider looks broken while
|
||||
// the new login looks lost. Returns user-facing guidance lines ("" when the
|
||||
// login has no catalog entry to scaffold from). Best-effort by design: the
|
||||
// token is already stored, so a profile-write failure degrades to a warning
|
||||
// line instead of failing a login that succeeded.
|
||||
func ensureLoginProviderProfile(deps appDeps, provider string) string {
|
||||
if _, ok := providercatalog.Get(provider); !ok {
|
||||
// Custom OAuth server without a catalog entry — no endpoint/model defaults
|
||||
// to scaffold a profile from; the user wires their own profile.
|
||||
return ""
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return "warning: login saved, but no provider profile was written: " + err.Error()
|
||||
}
|
||||
ensured, err := config.EnsureCatalogProvider(configPath, provider)
|
||||
if err != nil {
|
||||
return "warning: login saved, but no provider profile was written: " + err.Error()
|
||||
}
|
||||
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
|
||||
switch {
|
||||
case ensured.Created && active:
|
||||
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
|
||||
case ensured.Created:
|
||||
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
|
||||
case active:
|
||||
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
|
||||
default:
|
||||
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
|
||||
// additive and independent of `zero mcp oauth` (MCP server auth), which is
|
||||
// unchanged.
|
||||
@@ -83,11 +46,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
}
|
||||
|
||||
// runAuthOpenRouter runs OpenRouter's browser PKCE login and saves the freshly
|
||||
// minted API key into the user's provider credential store. Unlike `auth login`
|
||||
// (which stores an OAuth bearer token), OpenRouter's flow mints a normal API key;
|
||||
// persist it immediately so the provider is usable after the command completes.
|
||||
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
|
||||
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
|
||||
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
|
||||
// provider profile, while this command prints it for manual configuration.
|
||||
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" || a == "help" {
|
||||
_ = writeAuthHelp(stdout)
|
||||
@@ -99,7 +62,7 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
|
||||
}
|
||||
key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
|
||||
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
|
||||
Out: stdout,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
|
||||
@@ -108,59 +71,12 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
line, err := saveOpenRouterProviderKey(deps, key)
|
||||
if err != nil {
|
||||
if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil {
|
||||
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("OpenRouter returned an empty API key")
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := store.Set(ensured.Name, key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil {
|
||||
// Best-effort rollback: don't leave the key orphaned in the credential
|
||||
// store while config.json still says it isn't there.
|
||||
_, _ = store.Delete(ensured.Name)
|
||||
return "", err
|
||||
}
|
||||
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
|
||||
switch {
|
||||
case ensured.Created && active:
|
||||
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil
|
||||
case ensured.Created:
|
||||
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name), nil
|
||||
case active:
|
||||
return fmt.Sprintf("Provider %q is configured, active, and ready to use.", ensured.Name), nil
|
||||
default:
|
||||
return fmt.Sprintf("Provider %q is configured with the new key.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name), nil
|
||||
}
|
||||
}
|
||||
|
||||
// runAuthChatGPT runs the ChatGPT (Codex) browser PKCE login, persists the
|
||||
// bearer + chatgpt-account-id claim under the "chatgpt" provider key, and
|
||||
// prints a status block. The bearer routes to chatgpt.com/backend-api/codex
|
||||
@@ -222,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -417,11 +331,6 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if line := ensureLoginProviderProfile(deps, provider); line != "" {
|
||||
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -74,32 +72,6 @@ func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fillAppDeps also wraps probeProviderHealth, the code path zero providers
|
||||
// check --connectivity, zero doctor --connectivity, and the TUI doctor panel
|
||||
// all go through. Passing the raw resolved profile previously sent an
|
||||
// unauthenticated (keyless) health probe for apiKeyStored profiles, same
|
||||
// regression class as TestFillAppDepsWrapsNewProviderWithStoredKey but for
|
||||
// health checks instead of the runtime provider build.
|
||||
func TestFillAppDepsWrapsProbeProviderHealthWithStoredKey(t *testing.T) {
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-health")
|
||||
|
||||
var captured config.ProviderProfile
|
||||
deps := fillAppDeps(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
probeProviderHealth: func(_ context.Context, options providerhealth.Options) providerhealth.Result {
|
||||
captured = options.Profile
|
||||
return providerhealth.Result{Status: providerhealth.StatusPass}
|
||||
},
|
||||
})
|
||||
|
||||
deps.probeProviderHealth(context.Background(), providerhealth.Options{
|
||||
Profile: config.ProviderProfile{Name: "echo", APIKeyStored: true},
|
||||
})
|
||||
if captured.APIKey != "sk-stored-health" {
|
||||
t.Fatalf("wrapped probeProviderHealth ran with APIKey = %q, want the stored key (keyless health-check regression)", captured.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider (the TUI/exec STARTUP construction site) must export
|
||||
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
|
||||
// parent's provider from launch — not only after an in-session switch. Without
|
||||
|
||||
+1
-125
@@ -2,26 +2,18 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/provideroauth"
|
||||
)
|
||||
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test,
|
||||
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
|
||||
// ignore the temp path and hit the OS keychain.
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
func withAuthStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -153,44 +145,6 @@ func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthOpenRouterSavesMintedKey(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
code := runWithDeps([]string{"auth", "openrouter"}, &stdout, &stderr, appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
openRouterLogin: func(context.Context, provideroauth.OpenRouterOptions) (string, error) {
|
||||
return "sk-openrouter-test", nil
|
||||
},
|
||||
})
|
||||
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exit = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "new API key saved") {
|
||||
t.Fatalf("expected saved-key confirmation, got %q", stdout.String())
|
||||
}
|
||||
cfg := readCLIConfigFixture(t, configPath)
|
||||
if cfg.ActiveProvider != "openrouter" || len(cfg.Providers) != 1 {
|
||||
t.Fatalf("config = %#v", cfg)
|
||||
}
|
||||
profile := cfg.Providers[0]
|
||||
if profile.Name != "openrouter" || profile.CatalogID != "openrouter" || !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
||||
t.Fatalf("provider not stored-key sanitized: %#v", profile)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, ok, err := store.Get("openrouter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || key != "sk-openrouter-test" {
|
||||
t.Fatalf("stored key = %q, %v", key, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthHelp(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
@@ -243,81 +197,3 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
line := ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, `Added provider "chatgpt"`) {
|
||||
t.Fatalf("expected added-provider guidance, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "zero providers use chatgpt") {
|
||||
t.Fatalf("expected switch hint, got %q", line)
|
||||
}
|
||||
|
||||
cfg := readCLIConfigFixture(t, configPath)
|
||||
if cfg.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
|
||||
}
|
||||
names := make([]string, 0, len(cfg.Providers))
|
||||
for _, provider := range cfg.Providers {
|
||||
names = append(names, provider.Name)
|
||||
}
|
||||
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
|
||||
t.Fatalf("expected chatgpt profile appended, got %v", names)
|
||||
}
|
||||
|
||||
// A second login must be a no-op with switch guidance, not a duplicate.
|
||||
line = ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, "already configured") {
|
||||
t.Fatalf("expected already-configured guidance, got %q", line)
|
||||
}
|
||||
cfg = readCLIConfigFixture(t, configPath)
|
||||
if len(cfg.Providers) != 2 {
|
||||
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
line := ensureLoginProviderProfile(deps, "chatgpt")
|
||||
if !strings.Contains(line, "set it active") {
|
||||
t.Fatalf("fresh config should adopt the login as active, got %q", line)
|
||||
}
|
||||
cfg := readCLIConfigFixture(t, configPath)
|
||||
if cfg.ActiveProvider != "chatgpt" {
|
||||
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
|
||||
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
|
||||
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
|
||||
}
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Fatalf("config must not be created for a non-catalog login")
|
||||
}
|
||||
}
|
||||
|
||||
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
var cfg config.FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -125,9 +125,7 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
|
||||
return zerocommands.BackendLifecycleSnapshot{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
// Reporting/enumeration only, never spawns a server, so it is left ungated
|
||||
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
|
||||
cfg, err := deps.resolveMCPConfig(cwd, false)
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return zerocommands.BackendLifecycleSnapshot{}, err
|
||||
}
|
||||
@@ -154,9 +152,7 @@ func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error)
|
||||
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
// Reporting/enumeration only, never spawns a server, so it is left ungated
|
||||
// (excludeProject=false) to mirror the doctor/status hooks and plugins reports.
|
||||
cfg, err := deps.resolveMCPConfig(cwd, false)
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
|
||||
secret := "sk-proj-" + strings.Repeat("a", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestRunBackendsJSONUsesLifecycleSnapshotWithoutConnectingMCP(t *testing.T)
|
||||
func TestRunBackendsTextAndHelp(t *testing.T) {
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
loadHooks: func(hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
@@ -164,7 +164,7 @@ func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T
|
||||
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "http", URL: server.URL + "/mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -75,12 +75,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "use" {
|
||||
return runProvidersUse(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "remove" || command == "rm" {
|
||||
return runProvidersRemove(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "rename" {
|
||||
return runProvidersRename(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "setup" {
|
||||
return runProvidersSetup(args, stdout, stderr, deps)
|
||||
}
|
||||
@@ -252,20 +246,6 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
|
||||
|
||||
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
|
||||
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
|
||||
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
|
||||
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
|
||||
// the same candidate matching the runtime resolver uses.
|
||||
logins := oauthLoggedInProviders()
|
||||
if len(logins) > 0 {
|
||||
for index := range summary.Providers {
|
||||
for _, profile := range resolved.Providers {
|
||||
if profile.Name == summary.Providers[index].Name {
|
||||
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(summary.Providers, func(i int, j int) bool {
|
||||
if summary.Providers[i].Active != summary.Providers[j].Active {
|
||||
return summary.Providers[i].Active
|
||||
@@ -343,7 +323,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
|
||||
"model: "+displayCLIValue(provider.Model, "none"),
|
||||
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
|
||||
"base url: "+displayCLIValue(provider.BaseURL, "default"),
|
||||
"api key: "+providerCredentialState(provider),
|
||||
"api key: "+apiKeyState(provider.APIKeySet),
|
||||
)
|
||||
if provider.Message != "" {
|
||||
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
|
||||
@@ -360,7 +340,7 @@ func formatProviderLine(provider providerSummary) string {
|
||||
if provider.Active {
|
||||
marker = "*"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
|
||||
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
|
||||
if provider.Message != "" {
|
||||
line += " (" + provider.Status + ": " + provider.Message + ")"
|
||||
}
|
||||
@@ -433,15 +413,6 @@ func apiKeyState(set bool) string {
|
||||
return "not set"
|
||||
}
|
||||
|
||||
// providerCredentialState renders the credential column: a configured key wins,
|
||||
// then a stored OAuth login, then "not set".
|
||||
func providerCredentialState(provider providerSummary) string {
|
||||
if !provider.APIKeySet && provider.OAuthLogin {
|
||||
return "oauth login"
|
||||
}
|
||||
return apiKeyState(provider.APIKeySet)
|
||||
}
|
||||
|
||||
func displayCLIValue(value string, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
@@ -495,8 +466,6 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers add <catalog-id> [flags]
|
||||
zero providers check [name] [flags]
|
||||
zero providers use <name> [flags]
|
||||
zero providers remove <name> [flags]
|
||||
zero providers rename <old> <new> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
zero providers models [name] [flags]
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/zerocommands"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -836,57 +835,3 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
|
||||
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
|
||||
t.Fatalf("keyed provider = %q, want set", got)
|
||||
}
|
||||
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
|
||||
t.Fatalf("token-login provider = %q, want oauth login", got)
|
||||
}
|
||||
if got := providerCredentialState(providerSummary{}); got != "not set" {
|
||||
t.Fatalf("credential-less provider = %q, want not set", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
|
||||
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
|
||||
// as "oauth login", not as a broken "api key: not set" entry.
|
||||
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
|
||||
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
resolved := config.ResolvedConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
},
|
||||
}
|
||||
summary := summarizeConfig(resolved)
|
||||
byName := map[string]providerSummary{}
|
||||
for _, provider := range summary.Providers {
|
||||
byName[provider.Name] = provider
|
||||
}
|
||||
if !byName["opengateway"].APIKeySet {
|
||||
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
|
||||
}
|
||||
if byName["opengateway"].OAuthLogin {
|
||||
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
|
||||
}
|
||||
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
|
||||
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
|
||||
}
|
||||
|
||||
rendered := formatProviderSummaries("list", summary.Providers)
|
||||
if !strings.Contains(rendered, "oauth login") {
|
||||
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
@@ -177,7 +177,7 @@ func TestRunExecListToolsHonorsJSONFormat(t *testing.T) {
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return closeFunc(func() error { return nil }), nil
|
||||
@@ -234,7 +234,7 @@ func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
|
||||
Tools: config.ToolsConfig{DeferThreshold: 2},
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
)
|
||||
|
||||
// Default batch cloud models (§8). Overridable via stt.model.
|
||||
const (
|
||||
defaultGroqSTTModel = "whisper-large-v3-turbo"
|
||||
defaultOpenAISTTModel = "whisper-1"
|
||||
groqSTTBaseURL = "https://api.groq.com/openai/v1"
|
||||
)
|
||||
|
||||
// newDictationTranscriberFactory returns the closure the TUI calls to build a
|
||||
// transcriber when a recording starts. It lives here, not in the TUI, because it
|
||||
// resolves provider API keys (credstore + env + configured providers) — logic
|
||||
// that belongs with the rest of the CLI's credential handling.
|
||||
//
|
||||
// serverMgr is the shared, session-long sherpa-onnx streaming server; it is
|
||||
// spawned lazily on the first local-streaming recording and torn down on exit.
|
||||
//
|
||||
// preferStreaming asks for the streaming backend; the returned bool reports
|
||||
// whether streaming was actually built (false → the caller uses the batch
|
||||
// pipeline). A misconfigured streaming backend surfaces its own SetupError.
|
||||
func newDictationTranscriberFactory(resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) func(config.STTConfig, bool) (tui.Transcriber, bool, error) {
|
||||
return func(stt config.STTConfig, preferStreaming bool) (tui.Transcriber, bool, error) {
|
||||
// stt is passed in (not captured) so a mid-session change — e.g. the F9
|
||||
// auto-download writing localModelPath — takes effect on the next call.
|
||||
if serverMgr != nil {
|
||||
serverMgr.SetModelPath(stt.LocalModelPath)
|
||||
}
|
||||
if preferStreaming {
|
||||
t, err := buildStreamingTranscriber(stt, resolved, userConfigPath, serverMgr)
|
||||
switch {
|
||||
case err == nil && t != nil:
|
||||
return t, true, nil
|
||||
case err != nil && stt.STTStreamProvider() != config.STTProviderLocal:
|
||||
// An explicitly-chosen cloud streaming backend that failed to build
|
||||
// (e.g. a missing key) should surface its own error, not silently
|
||||
// fall back to batch — the user asked for that backend specifically.
|
||||
return nil, true, err
|
||||
}
|
||||
// The default local streaming backend isn't set up: fall through to the
|
||||
// batch pipeline so a cloud-batch provider (or a batch-only setup) still
|
||||
// works instead of erroring on a streaming server the user never asked for.
|
||||
}
|
||||
t, err := buildBatchTranscriber(stt, resolved, userConfigPath)
|
||||
return t, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// buildStreamingTranscriber constructs the configured streaming backend, or
|
||||
// returns (nil, nil) when streaming is not applicable so the caller falls back
|
||||
// to batch.
|
||||
func buildStreamingTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string, serverMgr *dictation.ServerManager) (dictation.Transcriber, error) {
|
||||
switch stt.STTStreamProvider() {
|
||||
case config.STTProviderDeepgram:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.StreamProviderDeepgram, "DEEPGRAM_API_KEY")
|
||||
return dictation.NewDeepgramTranscriber(dictation.DeepgramConfig{
|
||||
APIKey: key,
|
||||
Model: stt.StreamModel,
|
||||
Language: stt.Language,
|
||||
})
|
||||
case config.STTProviderOpenAI:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
|
||||
return dictation.NewOpenAIRealtimeTranscriber(dictation.OpenAIRealtimeConfig{
|
||||
APIKey: key,
|
||||
Model: stt.StreamModel,
|
||||
})
|
||||
default: // local streaming via the warm sherpa-onnx server
|
||||
// STTStreamProvider() defaults to "local" whenever streamProvider is unset —
|
||||
// including when the user picked a CLOUD batch provider (Groq/OpenAI) and
|
||||
// never touched streaming. Don't silently stream through the local sherpa
|
||||
// server in that case: local streaming is only appropriate when it was
|
||||
// explicitly requested (streamProvider: "local") OR the batch provider is
|
||||
// itself local. Otherwise report "not applicable" so the factory falls back
|
||||
// to the cloud batch pipeline the user actually chose.
|
||||
localWanted := stt.StreamProvider == config.STTProviderLocal || stt.STTProvider() == config.STTProviderLocal
|
||||
if !localWanted {
|
||||
return nil, nil
|
||||
}
|
||||
// The local streaming transcriber validates the model only lazily (at
|
||||
// connect time), so gate on the model path here — with none set, report
|
||||
// "not applicable" (nil, nil) so the factory falls back to the batch
|
||||
// pipeline instead of committing to a server that can't start.
|
||||
if serverMgr == nil || strings.TrimSpace(stt.LocalModelPath) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return dictation.NewLocalStreamingTranscriber(serverMgr), nil
|
||||
}
|
||||
}
|
||||
|
||||
// newDictationServerManager builds the warm sherpa-onnx streaming server manager
|
||||
// from config. Construction is side-effect free — the server spawns lazily on
|
||||
// the first local-streaming recording — so it is always safe to create.
|
||||
func newDictationServerManager(stt config.STTConfig) *dictation.ServerManager {
|
||||
return dictation.NewServerManager(dictation.ServerConfig{
|
||||
Binary: stt.LocalServerBinary,
|
||||
ModelPath: stt.LocalModelPath,
|
||||
Port: stt.LocalServerPort,
|
||||
NumThreads: stt.NumThreads,
|
||||
})
|
||||
}
|
||||
|
||||
// buildBatchTranscriber constructs the configured batch transcriber, resolving
|
||||
// credentials for the cloud providers.
|
||||
func buildBatchTranscriber(stt config.STTConfig, resolved config.ResolvedConfig, userConfigPath string) (dictation.Transcriber, error) {
|
||||
switch stt.STTProvider() {
|
||||
case config.STTProviderGroq:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderGroq, "GROQ_API_KEY")
|
||||
model := stt.Model
|
||||
if model == "" {
|
||||
model = defaultGroqSTTModel
|
||||
}
|
||||
return dictation.NewCloudTranscriber(dictation.CloudConfig{
|
||||
Provider: dictation.ProviderGroq,
|
||||
BaseURL: groqSTTBaseURL,
|
||||
APIKey: key,
|
||||
Model: model,
|
||||
Language: stt.Language,
|
||||
})
|
||||
case config.STTProviderOpenAI:
|
||||
key := resolveSTTKey(resolved, userConfigPath, dictation.ProviderOpenAI, "OPENAI_API_KEY")
|
||||
model := stt.Model
|
||||
if model == "" {
|
||||
model = defaultOpenAISTTModel
|
||||
}
|
||||
return dictation.NewCloudTranscriber(dictation.CloudConfig{
|
||||
Provider: dictation.ProviderOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: key,
|
||||
Model: model,
|
||||
Language: stt.Language,
|
||||
})
|
||||
default: // local
|
||||
return dictation.NewLocalTranscriber(dictation.LocalConfig{
|
||||
Binary: stt.LocalBinary,
|
||||
ModelPath: stt.LocalModelPath,
|
||||
NumThreads: stt.NumThreads,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sttEnvVar returns the conventional environment variable a cloud STT provider's
|
||||
// key is read from.
|
||||
func sttEnvVar(provider string) string {
|
||||
switch provider {
|
||||
case dictation.ProviderGroq:
|
||||
return "GROQ_API_KEY"
|
||||
case dictation.ProviderOpenAI: // == StreamProviderOpenAI ("openai")
|
||||
return "OPENAI_API_KEY"
|
||||
case dictation.StreamProviderDeepgram:
|
||||
return "DEEPGRAM_API_KEY"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newSTTKeyStatus returns a predicate reporting whether a cloud STT provider
|
||||
// already has a resolvable API key (configured provider, credstore, or env).
|
||||
func newSTTKeyStatus(resolved config.ResolvedConfig, userConfigPath string) func(string) bool {
|
||||
return func(provider string) bool {
|
||||
return resolveSTTKey(resolved, userConfigPath, provider, sttEnvVar(provider)) != ""
|
||||
}
|
||||
}
|
||||
|
||||
// newSaveSTTKey returns a function that stores a cloud STT provider's API key in
|
||||
// the encrypted credential store, so the inline prompt can persist it.
|
||||
func newSaveSTTKey(userConfigPath string) func(string, string) error {
|
||||
return func(provider, key string) error {
|
||||
if strings.TrimSpace(userConfigPath) == "" {
|
||||
return fmt.Errorf("no config path to store the key")
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.Set(provider, strings.TrimSpace(key))
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSTTKey finds an API key for an STT cloud provider, reusing Zero's
|
||||
// existing credentials (§2 — "zero new credential UI"). Precedence: a matching
|
||||
// configured provider's resolved key, then the encrypted credential store, then
|
||||
// the provider's conventional environment variable.
|
||||
func resolveSTTKey(resolved config.ResolvedConfig, userConfigPath, provider, envVar string) string {
|
||||
if key := keyFromConfiguredProviders(resolved, provider); key != "" {
|
||||
return key
|
||||
}
|
||||
if userConfigPath != "" {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(userConfigPath)); err == nil {
|
||||
if key, ok, _ := store.Get(provider); ok && strings.TrimSpace(key) != "" {
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(envVar))
|
||||
}
|
||||
|
||||
// keyFromConfiguredProviders returns the resolved API key of a configured
|
||||
// provider that belongs to the given cloud provider (matched by catalog id,
|
||||
// provider name/kind, or base-URL host), or "" when none is configured.
|
||||
func keyFromConfiguredProviders(resolved config.ResolvedConfig, provider string) string {
|
||||
provider = strings.ToLower(provider)
|
||||
profiles := append([]config.ProviderProfile{resolved.Provider}, resolved.Providers...)
|
||||
for _, p := range profiles {
|
||||
if strings.TrimSpace(p.APIKey) == "" {
|
||||
continue
|
||||
}
|
||||
if providerMatches(p, provider) {
|
||||
return strings.TrimSpace(p.APIKey)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerMatches(p config.ProviderProfile, provider string) bool {
|
||||
provider = strings.ToLower(provider)
|
||||
// Match on stable identifiers (catalog id, provider name/kind, profile
|
||||
// name) rather than raw BaseURL substring matching: a Groq profile's
|
||||
// BaseURL (api.groq.com/openai/v1) contains "openai", so a substring
|
||||
// check can hand the wrong key to an OpenAI STT lookup and fail auth.
|
||||
for _, f := range []string{
|
||||
strings.ToLower(p.CatalogID),
|
||||
strings.ToLower(p.Provider),
|
||||
strings.ToLower(string(p.ProviderKind)),
|
||||
strings.ToLower(p.Name),
|
||||
} {
|
||||
if f != "" && strings.Contains(f, provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// BaseURL: compare the parsed host only, not a raw substring on the
|
||||
// full URL (which lets "openai" match api.groq.com/openai/v1).
|
||||
if u, err := neturl.Parse(p.BaseURL); err == nil && u.Host != "" {
|
||||
if host := strings.ToLower(u.Host); strings.Contains(host, provider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/dictation"
|
||||
)
|
||||
|
||||
func TestFactoryFallsBackToBatchWhenLocalStreamingNotConfigured(t *testing.T) {
|
||||
t.Setenv("GROQ_API_KEY", "gsk-test")
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{
|
||||
Provider: config.STTProviderGroq, // cloud batch
|
||||
// StreamProvider unset → defaults to local, which has no model configured.
|
||||
}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
|
||||
|
||||
tr, streaming, err := factory(resolved.STT, true) // prefer streaming
|
||||
if err != nil {
|
||||
t.Fatalf("expected batch fallback, got error: %v", err)
|
||||
}
|
||||
if streaming {
|
||||
t.Error("should have fallen back to batch (streaming=false)")
|
||||
}
|
||||
if tr == nil {
|
||||
t.Error("expected a Groq batch transcriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryCloudProviderIgnoresLeftoverLocalModel(t *testing.T) {
|
||||
// Regression: pick local once (LocalModelPath gets set), then switch to a
|
||||
// cloud batch provider. streamProvider stays unset (defaults to "local"), so
|
||||
// the streaming factory must NOT fire up the sherpa server on the leftover
|
||||
// path — it should fall back to the cloud batch pipeline the user chose.
|
||||
t.Setenv("GROQ_API_KEY", "gsk-test")
|
||||
stt := config.STTConfig{
|
||||
Provider: config.STTProviderGroq, // cloud batch, chosen now
|
||||
LocalModelPath: "/leftover/model/dir", // set by an earlier local selection
|
||||
}
|
||||
resolved := config.ResolvedConfig{STT: stt}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(stt))
|
||||
|
||||
tr, streaming, err := factory(stt, true) // prefer streaming
|
||||
if err != nil {
|
||||
t.Fatalf("expected cloud batch fallback, got error: %v", err)
|
||||
}
|
||||
if streaming {
|
||||
t.Error("a cloud provider must not stream through the local sherpa server")
|
||||
}
|
||||
if tr == nil {
|
||||
t.Error("expected a Groq batch transcriber")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactorySurfacesExplicitCloudStreamingError(t *testing.T) {
|
||||
// Deepgram chosen explicitly but no key resolvable → surface the setup error.
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{
|
||||
StreamProvider: config.STTProviderDeepgram,
|
||||
}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", nil)
|
||||
|
||||
_, streaming, err := factory(resolved.STT, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected a setup error for missing Deepgram key")
|
||||
}
|
||||
if !streaming {
|
||||
t.Error("an explicit cloud streaming choice should report streaming=true even on error")
|
||||
}
|
||||
var setupErr *dictation.SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Errorf("want *SetupError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryLocalBatchSetupErrorWhenNoModel(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{STT: config.STTConfig{Provider: config.STTProviderLocal}}
|
||||
factory := newDictationTranscriberFactory(resolved, "", newDictationServerManager(resolved.STT))
|
||||
|
||||
_, _, err := factory(resolved.STT, true)
|
||||
var setupErr *dictation.SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError guiding local model setup, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSTTKeyPrefersConfiguredProvider(t *testing.T) {
|
||||
resolved := config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{
|
||||
{Name: "groq", CatalogID: "groq", BaseURL: "https://api.groq.com/openai/v1", APIKey: "gsk-configured"},
|
||||
},
|
||||
}
|
||||
if got := resolveSTTKey(resolved, "", dictation.ProviderGroq, "GROQ_API_KEY"); got != "gsk-configured" {
|
||||
t.Errorf("key = %q, want the configured provider's key", got)
|
||||
}
|
||||
}
|
||||
+6
-43
@@ -110,16 +110,6 @@ type execOptions struct {
|
||||
notifyMode string
|
||||
// noNotify forces ModeOff for this run. Mutually exclusive with notifyMode.
|
||||
noNotify bool
|
||||
// noCompletionGate turns off the headless completion gate
|
||||
// (agent.Options.RequireCompletionSignal) for this run. It exists for
|
||||
// CONVERSATIONAL exec callers — a chat frontend driving exec with an operator
|
||||
// present to reply (e.g. zeroclaw chat). There, a final message that hands
|
||||
// the turn back ("the sandbox blocks network egress, approve it and I'll
|
||||
// continue") is a COMPLETE answer, not an unfinished task, so the gate's
|
||||
// self-report admission check and INCOMPLETE downgrade (exit 4) would only
|
||||
// mislabel honest blocker reports. Default off: plain `zero exec` keeps the
|
||||
// gate, preserving CI/cron semantics.
|
||||
noCompletionGate bool
|
||||
// addDirs holds directories passed via --add-dir that should be allowed as
|
||||
// additional write roots for this run. Unioned with
|
||||
// config.SandboxConfig.AdditionalWriteRoots at scope construction time.
|
||||
@@ -165,10 +155,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
// trustRoot is the ORIGINAL launch directory, captured before any --worktree
|
||||
// reassignment below, so a worktree of a trusted repo inherits that repo's
|
||||
// trust instead of being seen as a fresh untrusted path.
|
||||
trustRoot := workspaceRoot
|
||||
if options.worktree {
|
||||
preparedWorktree, err := deps.prepareWorktree(context.Background(), worktrees.Options{
|
||||
Cwd: workspaceRoot,
|
||||
@@ -215,7 +201,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.useSpec {
|
||||
permissionMode = agent.PermissionModeSpecDraft
|
||||
}
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options), trustRoot)
|
||||
mcpRuntime, err := registerMCPToolsForWorkspace(context.Background(), workspaceRoot, registry, deps, execMCPAutonomy(options))
|
||||
if err != nil {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "mcp_error", err.Error())
|
||||
}
|
||||
@@ -224,7 +210,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// registry and collect their hooks + skill roots for the dispatcher and skill
|
||||
// tool below. Done before --list-tools and filter validation so plugin tools
|
||||
// are listable and filter-validatable; it fails OPEN (a bad plugin is skipped).
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr, trustRoot)
|
||||
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
|
||||
if options.useSpec {
|
||||
specmode.RegisterDraftTools(registry, workspaceRoot, deps.now)
|
||||
}
|
||||
@@ -429,8 +415,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
stderr: stderr,
|
||||
deps: deps,
|
||||
workspaceRoot: workspaceRoot,
|
||||
trustRoot: trustRoot,
|
||||
mcpSkip: mcpSkip,
|
||||
registry: registry,
|
||||
modelRegistry: modelRegistry,
|
||||
resolved: resolved,
|
||||
@@ -526,20 +510,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// lspShutdown tears down any language-server sessions either half spawned.
|
||||
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
|
||||
defer lspShutdown()
|
||||
// Kept as a named variable (rather than inlined into Options below) because
|
||||
// the write tools also use it for conflict checks across a run.
|
||||
fileTracker := tools.NewFileTracker()
|
||||
scratchBaseline := scratchFileSnapshot(workspaceRoot)
|
||||
emitScratchWarning := func() {
|
||||
if notice := scratchFileWarning(workspaceRoot, scratchBaseline); notice != "" {
|
||||
writer.warning(notice)
|
||||
}
|
||||
}
|
||||
// Build the hooks dispatcher out of the struct literal so its trust skip report
|
||||
// can be combined with the plugin activation's, and emit at most one notice when
|
||||
// project hooks/plugins were dropped for an untrusted workspace.
|
||||
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
|
||||
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
@@ -566,13 +536,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// Headless exec: don't accept a no-tool-call turn as "done" while work
|
||||
// clearly remains (pending plan items / a mid-step continuation cue) —
|
||||
// nudge to continue, and finalize as INCOMPLETE rather than false success
|
||||
// if the model keeps stalling. The interactive TUI leaves this off, and
|
||||
// --no-completion-gate lets conversational exec callers (a chat frontend
|
||||
// with an operator present) opt out the same way.
|
||||
RequireCompletionSignal: !options.noCompletionGate,
|
||||
// if the model keeps stalling. The interactive TUI leaves this off.
|
||||
RequireCompletionSignal: true,
|
||||
Sandbox: sandboxEngine,
|
||||
FileTracker: fileTracker,
|
||||
Hooks: hookDispatcher,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
EnabledTools: options.enabledTools,
|
||||
DisabledTools: options.disabledTools,
|
||||
OnText: writer.text,
|
||||
@@ -630,7 +598,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitCrash
|
||||
}
|
||||
if err != nil {
|
||||
emitScratchWarning()
|
||||
// A Ctrl+C / SIGTERM cancellation is a clean shutdown, not a provider error.
|
||||
if errors.Is(err, context.Canceled) || runCtx.Err() != nil {
|
||||
sessionRecorder.append(sessions.EventError, map[string]any{"message": "interrupted"})
|
||||
@@ -681,10 +648,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if notice := result.TruncationNotice(); notice != "" {
|
||||
writer.warning(notice)
|
||||
}
|
||||
// Surface new scratch/debug-like files left untracked in git when we finish —
|
||||
// including files created by shell commands that bypass the file tools (issue
|
||||
// #551). Normal new deliverable files stay quiet to avoid warning fatigue.
|
||||
emitScratchWarning()
|
||||
// A headless run the completion gate marked INCOMPLETE (no-tool-call stall,
|
||||
// self-reported non-completion, or max-turns cutoff) must NOT be reported as a
|
||||
// success: exit 4 AND a machine-readable terminal event saying so. Handled per
|
||||
|
||||
@@ -30,8 +30,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
options.selfCorrect = true
|
||||
case arg == "--no-notify":
|
||||
options.noNotify = true
|
||||
case arg == "--no-completion-gate":
|
||||
options.noCompletionGate = true
|
||||
case arg == "--notify":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -404,11 +402,6 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
// rather than pretend it took effect.
|
||||
return options, false, execUsageError{"--self-correct cannot be combined with --use-spec."}
|
||||
}
|
||||
if options.useSpec && options.noCompletionGate {
|
||||
// Same reasoning as --self-correct above: the spec-draft path never consults
|
||||
// the completion gate, so the flag would be silently ignored.
|
||||
return options, false, execUsageError{"--no-completion-gate cannot be combined with --use-spec."}
|
||||
}
|
||||
if !options.useSpec && options.specModel != "" {
|
||||
return options, false, execUsageError{"--spec-model requires --use-spec."}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
// --no-completion-gate exists for conversational exec callers (a chat frontend
|
||||
// with an operator present): an honest blocker report that hands the turn back
|
||||
// must not be downgraded to INCOMPLETE there. The flag maps to
|
||||
// agent.Options.RequireCompletionSignal = false; default keeps the gate on.
|
||||
func TestParseExecArgsNoCompletionGate(t *testing.T) {
|
||||
options, _, err := parseExecArgs([]string{"--prompt", "hi", "--no-completion-gate"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if !options.noCompletionGate {
|
||||
t.Fatal("--no-completion-gate must set noCompletionGate")
|
||||
}
|
||||
|
||||
options, _, err = parseExecArgs([]string{"--prompt", "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecArgs: %v", err)
|
||||
}
|
||||
if options.noCompletionGate {
|
||||
t.Fatal("noCompletionGate must default to false (gate on)")
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path never consults the completion gate, so the combination
|
||||
// must be rejected up front instead of silently ignoring the flag (mirrors the
|
||||
// existing --self-correct + --use-spec check).
|
||||
func TestParseExecArgsNoCompletionGateRejectsUseSpec(t *testing.T) {
|
||||
if _, _, err := parseExecArgs([]string{"--prompt", "hi", "--use-spec", "--no-completion-gate"}); err == nil {
|
||||
t.Fatal("--no-completion-gate with --use-spec must error")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -150,7 +149,7 @@ func TestRunExecListsMCPToolsWithoutProviderConstruction(t *testing.T) {
|
||||
providerBuilt = true
|
||||
return nil, errors.New("provider should not be constructed for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -193,7 +192,7 @@ func TestRunExecLogsMCPRuntimeCloseError(t *testing.T) {
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -713,48 +712,6 @@ func TestRunExecJSONInterruptedEmitsTerminalEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecWarnsAboutScratchFileWhenProviderErrors(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
runGitForScratchTest(t, cwd, "init")
|
||||
if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, cwd, "add", "README.md")
|
||||
runGitForScratchTest(t, cwd, "commit", "-m", "init")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "write scratch then fail"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return toolThenErrorExecProvider{
|
||||
toolCallID: "call_scratch",
|
||||
toolName: "write_file",
|
||||
arguments: `{"path":"_debug.py","content":"print('debug')"}`,
|
||||
err: errors.New("provider failed after tool"),
|
||||
}, nil
|
||||
},
|
||||
newSandboxStore: func() (*sandbox.GrantStore, error) {
|
||||
return sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("expected provider exit %d, got %d: stdout=%q stderr=%q", exitProvider, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
events := decodeJSONLines(t, stdout.String())
|
||||
warning := findJSONEvent(t, events, "warning")
|
||||
message, _ := warning["message"].(string)
|
||||
if !strings.Contains(message, "_debug.py") {
|
||||
t.Fatalf("expected scratch warning to mention _debug.py, got %#v", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecReadsStreamJSONPromptFromStdin(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
cwd := t.TempDir()
|
||||
@@ -833,13 +790,6 @@ type toolCallingExecProvider struct {
|
||||
answer string
|
||||
}
|
||||
|
||||
type toolThenErrorExecProvider struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
arguments string
|
||||
err error
|
||||
}
|
||||
|
||||
type reasoningExecProvider struct{}
|
||||
|
||||
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
@@ -875,26 +825,6 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (provider toolThenErrorExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
for _, message := range request.Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
return nil, provider.err
|
||||
}
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(ch)
|
||||
return ch, ctx.Err()
|
||||
case ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: provider.toolCallID, ToolName: provider.toolName}:
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: provider.toolCallID, ArgumentsFragment: provider.arguments}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: provider.toolCallID}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
|
||||
t.Helper()
|
||||
_, event := findJSONEventIndex(t, events, eventType)
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
|
||||
if !strings.Contains(out, "auth error:") {
|
||||
t.Fatalf("expected raw message, got %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "zero setup") || !strings.Contains(out, "zero auth openrouter") {
|
||||
t.Fatalf("expected an actionable setup/auth hint, got %q", out)
|
||||
if !strings.Contains(out, "zero auth") {
|
||||
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "zero setup") {
|
||||
if strings.Contains(stdout.String(), "zero auth") {
|
||||
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -21,19 +21,11 @@ import (
|
||||
)
|
||||
|
||||
type execSpecDraftRun struct {
|
||||
options execOptions
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
deps appDeps
|
||||
workspaceRoot string
|
||||
// trustRoot is the ORIGINAL launch directory (captured before any --worktree
|
||||
// reassignment), so a --use-spec run inside a --worktree of a trusted repo
|
||||
// still keys the trust check on the source repo, not the generated worktree path.
|
||||
trustRoot string
|
||||
// mcpSkip is the trust verdict from the workspace MCP registration in runExec
|
||||
// (which runs before this spec-draft path), so the spec-draft notice can report a
|
||||
// dropped project MCP layer instead of leaving it silent.
|
||||
mcpSkip trustSkip
|
||||
options execOptions
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
deps appDeps
|
||||
workspaceRoot string
|
||||
registry *tools.Registry
|
||||
modelRegistry modelregistry.Registry
|
||||
resolved config.ResolvedConfig
|
||||
@@ -104,13 +96,6 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
var draftInfo execSpecDraftInfo
|
||||
runCtx, stopSignals := signalContext()
|
||||
defer stopSignals()
|
||||
// The spec-draft path activates no plugins, so the plugin skip is omitted. Trust
|
||||
// keys on run.trustRoot (the original launch dir), not run.workspaceRoot, which
|
||||
// may be a --worktree path; this keeps a --use-spec --worktree run of a trusted
|
||||
// repo trusted. Emit at most one notice when project hooks or the project MCP
|
||||
// layer (registered earlier in runExec, carried in run.mcpSkip) were dropped.
|
||||
hookDispatcher, hookSkip := newHookDispatcher(run.workspaceRoot, run.trustRoot)
|
||||
emitTrustNotice(run.stderr, hookSkip, run.mcpSkip)
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
@@ -127,7 +112,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
Autonomy: "low",
|
||||
Sandbox: run.sandboxEngine,
|
||||
FileTracker: tools.NewFileTracker(),
|
||||
Hooks: hookDispatcher,
|
||||
Hooks: newHookDispatcher(run.workspaceRoot),
|
||||
EnabledTools: run.options.enabledTools,
|
||||
DisabledTools: run.options.disabledTools,
|
||||
OnText: writer.text,
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestRunExecUseSpecCreatesDraftSession(t *testing.T) {
|
||||
MaxTurns: 3,
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
|
||||
@@ -205,9 +205,7 @@ func runMCPLegacyList(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||
}
|
||||
// Enumeration for the extensions listing, never spawns a server, so it is left
|
||||
// ungated (excludeProject=false) like the other doctor/status report sites.
|
||||
cfg, err := deps.resolveMCPConfig(cwd, false)
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
@@ -288,21 +286,11 @@ func runMCPTools(ctx context.Context, args []string, stdout io.Writer, stderr io
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
// `mcp tools list` connects to (spawns) each server to enumerate its live tools,
|
||||
// so it is a spawn site and gates the project layer behind the trust check. No
|
||||
// --worktree reassignment on this command path, so trustRoot == cwd.
|
||||
mcpRuntime, mcpSkip, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow, cwd)
|
||||
mcpRuntime, err := registerMCPToolsForWorkspace(ctx, cwd, registry, deps, mcp.AutonomyLow)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
|
||||
// workspace is explained rather than read as "nothing configured". The notice
|
||||
// goes to stderr, leaving the list (text or --json) on stdout intact.
|
||||
emitTrustNotice(stderr, mcpSkip)
|
||||
// Surface the trust skip on stderr so an empty (or short) list in an untrusted
|
||||
// workspace is explained rather than read as "nothing configured". The notice
|
||||
// goes to stderr, leaving the list (text or --json) on stdout intact.
|
||||
items := mcpToolList(registry)
|
||||
if options.json {
|
||||
payload := struct {
|
||||
|
||||
@@ -204,7 +204,7 @@ func TestRunMCPToolsListJSONAndText(t *testing.T) {
|
||||
closeCalls := 0
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestRunMCPLegacyListAliases(t *testing.T) {
|
||||
closeCalls := 0
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,17 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// trustSkip reports whether a runtime chokepoint dropped the project layer because
|
||||
// the workspace was not trusted, so the caller can emit a single combined notice.
|
||||
// Both chokepoints (hooks and plugins) return one, and the caller ORs them.
|
||||
type trustSkip struct {
|
||||
// excludedProjectConfig is true when the project layer was excluded AND a
|
||||
// project config for this surface actually exists on disk (so a notice is
|
||||
// worth showing). It stays false in a trusted workspace, or when the
|
||||
// workspace has no project config to skip.
|
||||
excludedProjectConfig bool
|
||||
// trustCheckErrored is true when the exclusion was caused by a trust-store
|
||||
// read error rather than a clean untrusted verdict, so the caller can name
|
||||
// the error in its notice.
|
||||
trustCheckErrored bool
|
||||
}
|
||||
|
||||
// resolveTrust computes the fail-closed trust verdict for a chokepoint. It treats
|
||||
// any error OR an empty trustRoot as untrusted, so a forgotten or future call site
|
||||
// cannot fail open. It returns whether the project layer should be excluded and
|
||||
// whether the decision was driven by a store-read error.
|
||||
//
|
||||
// Each chokepoint (hooks and plugins) calls this itself rather than the caller
|
||||
// resolving trust once and passing a bool down. That is deliberate: keeping the
|
||||
// check inside the chokepoint is what makes the gate fail-closed by construction.
|
||||
// Do NOT hoist it to the callers behind an excludeProject bool whose zero value
|
||||
// includes the project layer, that reintroduces a fail-open default. The extra
|
||||
// store read per session is negligible.
|
||||
func resolveTrust(trustRoot string) (excludeProject bool, trustCheckErrored bool) {
|
||||
if trustRoot == "" {
|
||||
return true, false
|
||||
}
|
||||
trusted, err := workspacetrust.IsTrusted(trustRoot)
|
||||
if err != nil {
|
||||
return true, true
|
||||
}
|
||||
return !trusted, false
|
||||
}
|
||||
|
||||
// newHookDispatcher builds the per-session hooks dispatcher for a workspace,
|
||||
// merging user + project hooks.json and wiring the audit store. It fails OPEN:
|
||||
// any load or setup error yields a nil dispatcher, which Dispatch treats as a
|
||||
// no-op, so a malformed hooks config can never wedge tool execution. With no
|
||||
// hooks configured the dispatcher selects nothing and runs no commands, so the
|
||||
// hot path stays free of overhead until a user opts in via hooks.json.
|
||||
//
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment); the project layer loads only when that root is trusted.
|
||||
func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil, trustRoot)
|
||||
func newHookDispatcher(workspaceRoot string) *hooks.Dispatcher {
|
||||
return newHookDispatcherWithExtra(workspaceRoot, nil)
|
||||
}
|
||||
|
||||
// newHookDispatcherWithExtra builds the dispatcher like newHookDispatcher but also
|
||||
@@ -66,22 +20,10 @@ func newHookDispatcher(workspaceRoot string, trustRoot string) (*hooks.Dispatche
|
||||
// appended after the configured hooks; their ids are plugin-namespaced (plugin
|
||||
// id + hook name) so they never collide with hooks.json ids. A nil/empty extra
|
||||
// slice is byte-equivalent to newHookDispatcher.
|
||||
//
|
||||
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
|
||||
// the project layer is dropped (ExcludeProject) whenever trustRoot is empty, the
|
||||
// trust store cannot be read, or the workspace is not trusted (fail-closed). The
|
||||
// returned trustSkip lets the caller emit one combined notice; the notice itself
|
||||
// is NOT emitted here.
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition, trustRoot string) (*hooks.Dispatcher, trustSkip) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectHooksFileExists(workspaceRoot),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
}
|
||||
|
||||
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
|
||||
func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition) *hooks.Dispatcher {
|
||||
loaded, err := hooks.LoadConfig(hooks.LoadOptions{Cwd: workspaceRoot})
|
||||
if err != nil {
|
||||
return nil, skip
|
||||
return nil
|
||||
}
|
||||
var audit *hooks.AuditStore
|
||||
if store, err := hooks.NewAuditStore(hooks.AuditStoreOptions{}); err == nil {
|
||||
@@ -110,42 +52,5 @@ func newHookDispatcherWithExtra(workspaceRoot string, extra []hooks.Definition,
|
||||
Config: config,
|
||||
Audit: audit,
|
||||
Cwd: workspaceRoot,
|
||||
}), skip
|
||||
}
|
||||
|
||||
// projectHooksFileExists reports whether a ./.zero/hooks.json is present under
|
||||
// workspaceRoot, so the caller only notices about config it actually skipped.
|
||||
func projectHooksFileExists(workspaceRoot string) bool {
|
||||
if workspaceRoot == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "hooks.json"))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// emitTrustNotice writes at most one stderr line summarizing that project-scoped
|
||||
// hooks, plugins, and/or MCP servers were skipped in an untrusted workspace. It
|
||||
// takes the skip report from each trust-gated surface (hooks, plugins, MCP) and ORs
|
||||
// them, so a workspace that only drops one surface still gets the notice. It is
|
||||
// computed once per session by the caller (each session-setup site runs once), so it
|
||||
// is naturally once-per-process. When any surface's skip was a trust-store read
|
||||
// error, the notice names that so a transient config-dir problem is diagnosable.
|
||||
func emitTrustNotice(stderr io.Writer, skips ...trustSkip) {
|
||||
if stderr == nil {
|
||||
return
|
||||
}
|
||||
excluded := false
|
||||
storeErrored := false
|
||||
for _, skip := range skips {
|
||||
excluded = excluded || skip.excludedProjectConfig
|
||||
storeErrored = storeErrored || skip.trustCheckErrored
|
||||
}
|
||||
if !excluded {
|
||||
return
|
||||
}
|
||||
if storeErrored {
|
||||
_, _ = fmt.Fprintln(stderr, "zero: the workspace-trust store could not be read; ignoring project hooks/plugins/MCP servers (fail-closed). Run 'zero trust' to enable.")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintln(stderr, "zero: ignoring project hooks/plugins/MCP servers in an untrusted workspace. Run 'zero trust' to enable.")
|
||||
})
|
||||
}
|
||||
@@ -519,7 +519,7 @@ func TestRunMCPListRedactsURLCredentialsAndSensitiveQueryParams(t *testing.T) {
|
||||
commandSecret := "sk-proj-" + strings.Repeat("a", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -566,7 +566,7 @@ func TestRunMCPCheckRegistersOnlyRequestedServer(t *testing.T) {
|
||||
|
||||
exitCode := runWithDeps([]string{"mcp", "check", "docs", "--json"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
@@ -618,7 +618,7 @@ func TestRunMCPCheckClosesRuntimeReturnedWithError(t *testing.T) {
|
||||
|
||||
exitCode := runWithDeps([]string{"mcp", "check", "docs"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
|
||||
@@ -29,27 +29,6 @@ type mcpWritableConfig struct {
|
||||
serverRaw map[string]json.RawMessage
|
||||
}
|
||||
|
||||
// projectMCPConfigExists reports whether the workspace's project ./.zero/config.json
|
||||
// declares any MCP servers, so the trust notice fires only when project MCP config was
|
||||
// actually skipped (mirroring projectHooksFileExists / projectPluginsDirExists). A
|
||||
// missing or unparseable file, or one that declares no servers, returns false: there
|
||||
// is nothing to notice about. This only reads the file; it never spawns a server, so
|
||||
// it is safe to call on an untrusted workspace.
|
||||
func projectMCPConfigExists(workspaceRoot string) bool {
|
||||
if workspaceRoot == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(workspaceRoot, ".zero", "config.json"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var fc config.FileConfig
|
||||
if err := json.Unmarshal(data, &fc); err != nil {
|
||||
return false
|
||||
}
|
||||
return len(fc.MCP.Servers) > 0
|
||||
}
|
||||
|
||||
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseMCPAddArgs(args)
|
||||
if err != nil {
|
||||
@@ -264,25 +243,12 @@ func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
||||
}
|
||||
// `mcp check` connects to (spawns) the named server below to enumerate its tools,
|
||||
// so it is a spawn site: gate the project layer behind the trust check (fail-closed)
|
||||
// so a cloned repo cannot have `zero mcp check <its-server>` run its command. No
|
||||
// --worktree reassignment on this command path, so trustRoot == cwd.
|
||||
mcpExcludeProject, trustCheckErrored := resolveTrust(cwd)
|
||||
cfg, err := deps.resolveMCPConfig(cwd, mcpExcludeProject)
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
raw, ok := cfg.Servers[serverName]
|
||||
if !ok {
|
||||
// The server may be missing because the project layer was gated out in an
|
||||
// untrusted workspace; emit the same one-line notice the other spawn sites use so
|
||||
// a dropped project server reads as "run zero trust", not a bare miss. The notice
|
||||
// self-gates: it stays silent unless a project MCP config was actually excluded.
|
||||
emitTrustNotice(stderr, trustSkip{
|
||||
excludedProjectConfig: mcpExcludeProject && projectMCPConfigExists(cwd),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
})
|
||||
return writeAppError(stderr, fmt.Sprintf("MCP server %q is not configured", serverName), exitCrash)
|
||||
}
|
||||
if raw.Disabled {
|
||||
|
||||
+13
-31
@@ -55,12 +55,8 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
}
|
||||
serverName := positional[0]
|
||||
|
||||
server, skip, err := resolveOAuthServer(deps, serverName)
|
||||
server, err := resolveOAuthServer(deps, serverName)
|
||||
if err != nil {
|
||||
// When the named server was dropped because the workspace is untrusted, explain
|
||||
// that with the same one-line notice the other trust-gated MCP paths emit, so a
|
||||
// gated project OAuth server reads as "run zero trust", not a bare miss.
|
||||
emitTrustNotice(stderr, skip)
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
@@ -86,7 +82,7 @@ func runMCPOAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps ap
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := store.SaveForServer(server, token); err != nil {
|
||||
if err := store.Save(serverName, token); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
|
||||
@@ -116,7 +112,7 @@ func runMCPOAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := store.DeleteForServerName(serverName)
|
||||
removed, err := store.Delete(serverName)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
@@ -197,47 +193,33 @@ func filterTokenStatuses(statuses []mcp.TokenStatus, serverName string) []mcp.To
|
||||
}
|
||||
|
||||
// resolveOAuthServer loads the workspace MCP config and returns the named server
|
||||
// after verifying it declares OAuth authentication. It also returns a trustSkip so the
|
||||
// caller can emit the workspace-trust notice when a project server was dropped.
|
||||
//
|
||||
// Login is gated behind workspace trust (fail-closed) like the stdio spawn paths: it
|
||||
// loads a project-scoped MCP server and drives its OAuth flow (endpoint discovery,
|
||||
// dynamic client registration, code exchange, token persist) against the server's
|
||||
// configured URL. That is a project-scoped server being activated and contacted, so an
|
||||
// untrusted clone's ./.zero/config.json OAuth server must be dropped rather than loaded —
|
||||
// leaving it ungated would let a cloned repo initiate outbound OAuth I/O and persist a
|
||||
// token. There is no --worktree reassignment on this command path, so trustRoot == cwd.
|
||||
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, trustSkip, error) {
|
||||
// after verifying it declares OAuth authentication.
|
||||
func resolveOAuthServer(deps appDeps, serverName string) (mcp.Server, error) {
|
||||
if err := mcp.ValidateServerName(serverName); err != nil {
|
||||
return mcp.Server{}, trustSkip{}, err
|
||||
return mcp.Server{}, err
|
||||
}
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return mcp.Server{}, trustSkip{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
return mcp.Server{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
excludeProject, trustCheckErrored := resolveTrust(cwd)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(cwd),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(cwd, excludeProject)
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return mcp.Server{}, skip, err
|
||||
return mcp.Server{}, err
|
||||
}
|
||||
servers, err := mcp.NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
return mcp.Server{}, skip, err
|
||||
return mcp.Server{}, err
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name != serverName {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(server.Auth, mcp.ServerAuthOAuth) {
|
||||
return mcp.Server{}, skip, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
|
||||
return mcp.Server{}, fmt.Errorf("MCP server %q does not declare auth: \"oauth\"", serverName)
|
||||
}
|
||||
return server, skip, nil
|
||||
return server, nil
|
||||
}
|
||||
return mcp.Server{}, skip, fmt.Errorf("MCP server %q is not configured", serverName)
|
||||
return mcp.Server{}, fmt.Errorf("MCP server %q is not configured", serverName)
|
||||
}
|
||||
|
||||
func oauthConfigForServer(server mcp.Server) mcp.OAuthConfig {
|
||||
|
||||
+17
-316
@@ -6,17 +6,14 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// syncBuffer is a goroutine-safe writer used when a background goroutine reads
|
||||
@@ -88,9 +85,6 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if err := store.Save("remote", mcp.StoredToken{AccessToken: "a", RefreshToken: "r"}); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
if err := store.SaveForServer(mcp.Server{Name: "remote", Identity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, mcp.StoredToken{AccessToken: "identity", RefreshToken: "ir"}); err != nil {
|
||||
t.Fatalf("SaveForServer() error = %v", err)
|
||||
}
|
||||
deps := appDeps{newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil }}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -104,9 +98,6 @@ func TestRunMCPOAuthLogout(t *testing.T) {
|
||||
if _, ok, _ := store.Load("remote"); ok {
|
||||
t.Fatal("token still present after logout")
|
||||
}
|
||||
if statuses, err := store.Status(); err != nil || len(statuses) != 0 {
|
||||
t.Fatalf("status after logout = %#v err=%v, want no tokens", statuses, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
@@ -128,24 +119,23 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
}
|
||||
|
||||
cwd := t.TempDir()
|
||||
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return mcpConfig, nil
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
@@ -180,13 +170,9 @@ func TestRunMCPOAuthLoginStoresTokens(t *testing.T) {
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
servers, err := mcp.NormalizeConfig(mcpConfig)
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
token, ok, err := store.Load("remote")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadForServer() ok=%v err=%v", ok, err)
|
||||
t.Fatalf("Load() ok=%v err=%v", ok, err)
|
||||
}
|
||||
if token.AccessToken != "access-final" || token.RefreshToken != "refresh-final" {
|
||||
t.Fatalf("stored token = %#v", token)
|
||||
@@ -208,7 +194,7 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"plain": {Type: "http", URL: "https://plain.invalid/mcp"},
|
||||
}}, nil
|
||||
@@ -224,291 +210,6 @@ func TestRunMCPOAuthLoginRejectsNonOAuthServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPOAuthLoginGatedInUntrustedWorkspace is the load-bearing security test for
|
||||
// the OAuth login gate: an OAuth MCP server defined ONLY in an untrusted workspace's
|
||||
// ./.zero/config.json must be dropped before login. `zero mcp oauth login` must refuse
|
||||
// with "not configured", surface the trust notice, and store NO token — so the
|
||||
// discovery/registration/token-exchange flow never fires against the project URL. If the
|
||||
// gate is reverted to resolveMCPConfig(cwd, false), the server survives, login proceeds,
|
||||
// a token is stored, and every assertion below fails.
|
||||
func TestRunMCPOAuthLoginGatedInUntrustedWorkspace(t *testing.T) {
|
||||
setTrustConfigRoot(t) // isolates the trust store; the workspace stays untrusted
|
||||
// A live server standing in for the untrusted project's OAuth server. A gated login
|
||||
// must never reach it, so hits must stay 0 — a direct assertion of no outbound I/O
|
||||
// (discovery/registration/token exchange) against a cloned repo's configured URL. If
|
||||
// the gate leaks, mcp.Login contacts this URL and the hit count rises.
|
||||
var hits atomic.Int64
|
||||
oauthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer oauthSrv.Close()
|
||||
|
||||
cwd := t.TempDir()
|
||||
// A real project MCP config on disk so projectMCPConfigExists() is true and the
|
||||
// notice fires. resolveMCPConfig is faked, but the notice detector reads disk.
|
||||
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"` + oauthSrv.URL + `","auth":"oauth"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
// Points every OAuth endpoint at the live server: if the gate leaks and login
|
||||
// proceeds, the flow contacts oauthSrv and hits.Load() becomes non-zero.
|
||||
projectServer := config.MCPServerConfig{
|
||||
Type: "http",
|
||||
URL: oauthSrv.URL,
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: oauthSrv.URL + "/authorize",
|
||||
TokenEndpoint: oauthSrv.URL + "/token",
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
}
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["remote"] = projectServer
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
// The gate must short-circuit BEFORE the interactive OAuth flow. Bound the run so a
|
||||
// regression that drops the gate (login proceeds and blocks on the loopback callback)
|
||||
// fails here in 10s instead of hanging until the package test timeout.
|
||||
stdout := &syncBuffer{}
|
||||
stderr := &syncBuffer{}
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
|
||||
}()
|
||||
var exitCode int
|
||||
select {
|
||||
case exitCode = <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("login was not gated; it blocked on the OAuth flow — the untrusted gate is missing. stderr=%q", stderr.String())
|
||||
}
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("login on a project-only OAuth server in an untrusted workspace must fail; stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "not configured") {
|
||||
t.Fatalf("stderr must report the server as not configured, got %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "MCP servers") || !strings.Contains(stderr.String(), "zero trust") {
|
||||
t.Fatalf("stderr must surface the workspace-trust notice, got %q", stderr.String())
|
||||
}
|
||||
if _, ok, _ := store.Load("remote"); ok {
|
||||
t.Fatal("no token must be stored when login is gated")
|
||||
}
|
||||
if got := hits.Load(); got != 0 {
|
||||
t.Fatalf("gated login must never contact the project OAuth server URL, got %d hit(s)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerTrustedWorkspaceReturnsServer proves the gate does not
|
||||
// over-block: in a TRUSTED workspace the project OAuth server resolves normally with a
|
||||
// clean skip, so login proceeds as before. Calls resolveOAuthServer directly to avoid
|
||||
// driving the full interactive login flow.
|
||||
func TestResolveOAuthServerTrustedWorkspaceReturnsServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
cwd := t.TempDir()
|
||||
if err := workspacetrust.Trust(cwd); err != nil {
|
||||
t.Fatalf("Trust(cwd): %v", err)
|
||||
}
|
||||
var gotExclude bool
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
gotExclude = excludeProject
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["remote"] = oauthTestServerConfig()
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
}
|
||||
|
||||
server, skip, err := resolveOAuthServer(deps, "remote")
|
||||
if err != nil {
|
||||
t.Fatalf("trusted workspace must resolve the project OAuth server, got err %v", err)
|
||||
}
|
||||
if gotExclude {
|
||||
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
|
||||
}
|
||||
if server.Name != "remote" {
|
||||
t.Fatalf("server.Name = %q, want remote", server.Name)
|
||||
}
|
||||
if skip.excludedProjectConfig {
|
||||
t.Fatalf("trusted workspace must not report an excluded project config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerGatedInUntrustedWorkspace is the fast load-bearing guard for the
|
||||
// gate: in an untrusted workspace a project-only OAuth server is dropped, so resolution
|
||||
// fails with "not configured" and flags the excluded project config for the notice.
|
||||
// Reverting the gate to resolveMCPConfig(cwd, false) makes the server resolve and this
|
||||
// test fails immediately (no 10s login-flow timeout needed).
|
||||
func TestResolveOAuthServerGatedInUntrustedWorkspace(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
cwd := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(cwd, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"remote":{"type":"http","url":"https://remote.invalid/mcp","auth":"oauth"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(cwd, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["remote"] = oauthTestServerConfig()
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, skip, err := resolveOAuthServer(deps, "remote")
|
||||
if err == nil {
|
||||
t.Fatal("untrusted workspace must not resolve a project-only OAuth server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not configured") {
|
||||
t.Fatalf("want not-configured error, got %v", err)
|
||||
}
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatal("a dropped project OAuth config must flag excludedProjectConfig for the notice")
|
||||
}
|
||||
if skip.trustCheckErrored {
|
||||
t.Fatal("a clean untrusted verdict is not a store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
func oauthTestServerConfig() config.MCPServerConfig {
|
||||
return config.MCPServerConfig{
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: "https://remote.invalid/token",
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPOAuthLoginTrustedProjectServerStoresToken proves the gate does not over-block
|
||||
// end-to-end: in a TRUSTED workspace, a project-only OAuth server (returned by the fake
|
||||
// ONLY when excludeProject is false) is resolved, the full login flow runs, and the token
|
||||
// is persisted with exitSuccess. This exercises the trusted-resolve + login-success path
|
||||
// as one execution; if trust were dropped, excludeProject would be true, the fake would
|
||||
// drop the server, and login would fail with "not configured".
|
||||
func TestRunMCPOAuthLoginTrustedProjectServerStoresToken(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "access-final",
|
||||
"refresh_token": "refresh-final",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
cwd := t.TempDir()
|
||||
if err := workspacetrust.Trust(cwd); err != nil {
|
||||
t.Fatalf("Trust(cwd): %v", err)
|
||||
}
|
||||
store, err := mcp.NewTokenStore(mcp.TokenStoreOptions{
|
||||
FilePath: filepath.Join(t.TempDir(), "mcp-oauth-tokens.json"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore() error = %v", err)
|
||||
}
|
||||
projectServer := config.MCPServerConfig{
|
||||
Type: "http",
|
||||
URL: "https://remote.invalid/mcp",
|
||||
Auth: "oauth",
|
||||
OAuth: &config.MCPOAuthConfig{
|
||||
ClientID: "client-123",
|
||||
AuthorizationEndpoint: "https://remote.invalid/authorize",
|
||||
TokenEndpoint: tokenServer.URL,
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
}
|
||||
var gotExclude bool
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
newMCPTokenStore: func() (*mcp.TokenStore, error) { return store, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
gotExclude = excludeProject
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["remote"] = projectServer
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
stdout := &syncBuffer{}
|
||||
stderr := &syncBuffer{}
|
||||
go func() {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
callbackURL := extractCallbackURL(stdout.String())
|
||||
if callbackURL != "" {
|
||||
_, _ = http.Get(callbackURL)
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
done := make(chan int, 1)
|
||||
go func() {
|
||||
done <- runWithDeps([]string{"mcp", "oauth", "login", "remote"}, stdout, stderr, deps)
|
||||
}()
|
||||
var exitCode int
|
||||
select {
|
||||
case exitCode = <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("trusted login did not complete within 10s; stderr=%s stdout=%s", stderr.String(), stdout.String())
|
||||
}
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("trusted login must succeed; exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if gotExclude {
|
||||
t.Fatal("trusted workspace must resolve MCP config with excludeProject=false")
|
||||
}
|
||||
servers, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": projectServer,
|
||||
}})
|
||||
if err != nil || len(servers) != 1 {
|
||||
t.Fatalf("NormalizeConfig() servers=%#v err=%v", servers, err)
|
||||
}
|
||||
token, ok, err := store.LoadForServer(servers[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("token must be stored on trusted login; ok=%v err=%v", ok, err)
|
||||
}
|
||||
if token.AccessToken != "access-final" {
|
||||
t.Fatalf("stored token = %#v", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMCPOAuthUnknownSubcommand(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"mcp", "oauth", "bogus"}, &stdout, &stderr, appDeps{})
|
||||
|
||||
@@ -19,38 +19,22 @@ type mcpToolListItem struct {
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
// registerMCPToolsForWorkspace resolves and registers the workspace's MCP servers.
|
||||
// This spawns stdio servers, so it gates the project config layer behind the
|
||||
// workspace-trust check: trustRoot is the ORIGINAL launch directory (resolved before
|
||||
// any --worktree reassignment) so a worktree of a trusted repo inherits that trust.
|
||||
// resolveTrust fails closed, so an empty trustRoot or a store-read error excludes the
|
||||
// project layer and a cloned repo cannot spawn its ./.zero/config.json MCP servers.
|
||||
//
|
||||
// It returns a trustSkip alongside the runtime so the caller can fold the MCP gate
|
||||
// into the one-line trust notice (mirroring the hooks and plugins chokepoints);
|
||||
// otherwise a workspace whose only project config is MCP would be gated silently.
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy, trustRoot string) (mcpToolRuntime, trustSkip, error) {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectMCPConfigExists(workspaceRoot),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
}
|
||||
cfg, err := deps.resolveMCPConfig(workspaceRoot, excludeProject)
|
||||
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy) (mcpToolRuntime, error) {
|
||||
cfg, err := deps.resolveMCPConfig(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, skip, err
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.Servers) == 0 {
|
||||
return noopMCPRuntime{}, skip, nil
|
||||
return noopMCPRuntime{}, nil
|
||||
}
|
||||
store, err := deps.newMCPStore()
|
||||
if err != nil {
|
||||
return nil, skip, err
|
||||
return nil, err
|
||||
}
|
||||
runtime, err := deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
return deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
||||
PermissionStore: store,
|
||||
Autonomy: autonomy,
|
||||
})
|
||||
return runtime, skip, err
|
||||
}
|
||||
|
||||
func execMCPAutonomy(options execOptions) mcp.PermissionAutonomy {
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
package cli
|
||||
|
||||
// Edge-case coverage for the MCP trust gate on the oauth-login and check paths:
|
||||
// the error-return contract of the gated resolveOAuthServer, the trust-store-read-error
|
||||
// notice variant, and the accepted (advisory) notice over-emission on the not-oauth path.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// writeProjectMCPConfig drops a ./.zero/config.json under dir declaring one MCP server,
|
||||
// so projectMCPConfigExists() reads it as present.
|
||||
func writeProjectMCPConfig(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// breakTrustStore makes the trust store unreadable (creates trust.json as a directory),
|
||||
// so workspacetrust.IsTrusted returns an error and resolveTrust fails closed with
|
||||
// trustCheckErrored=true.
|
||||
func breakTrustStore(t *testing.T) {
|
||||
t.Helper()
|
||||
configRoot := setTrustConfigRoot(t)
|
||||
if err := os.MkdirAll(filepath.Join(configRoot, "zero", "trust.json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerValidationErrorReturnsZeroSkip locks the new 3-value contract on
|
||||
// the pre-resolve error path: an invalid server name errors before trust is resolved, so
|
||||
// the returned skip is zero (nothing was excluded yet).
|
||||
func TestResolveOAuthServerValidationErrorReturnsZeroSkip(t *testing.T) {
|
||||
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
|
||||
_, skip, err := resolveOAuthServer(deps, "bad name with spaces")
|
||||
if err == nil {
|
||||
t.Fatal("invalid server name must error")
|
||||
}
|
||||
if skip.excludedProjectConfig || skip.trustCheckErrored {
|
||||
t.Fatalf("a validation error must return a zero skip, got %+v", skip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerGetwdErrorPropagates covers the getwd error path of the gated
|
||||
// resolver.
|
||||
func TestResolveOAuthServerGetwdErrorPropagates(t *testing.T) {
|
||||
deps := appDeps{getwd: func() (string, error) { return "", errors.New("boom") }}
|
||||
_, _, err := resolveOAuthServer(deps, "remote")
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to resolve workspace") {
|
||||
t.Fatalf("getwd error must propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPOAuthLoginStoreErrorNotice proves the store-read-error branch: when the trust
|
||||
// store cannot be read, login fails closed AND the notice names the store error (the
|
||||
// "could not be read" variant) rather than the plain untrusted wording.
|
||||
func TestRunMCPOAuthLoginStoreErrorNotice(t *testing.T) {
|
||||
breakTrustStore(t)
|
||||
cwd := t.TempDir()
|
||||
writeProjectMCPConfig(t, cwd)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["proj"] = config.MCPServerConfig{Type: "http", URL: "https://x.invalid", Auth: "oauth"}
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "oauth", "login", "proj"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatal("login must fail closed on a trust-store read error")
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "could not be read") || !strings.Contains(got, "not configured") {
|
||||
t.Fatalf("store-error login must emit the store-errored notice AND not-configured, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckStoreErrorNotice is the mcp-check sibling of the store-error case.
|
||||
func TestRunMCPCheckStoreErrorNotice(t *testing.T) {
|
||||
breakTrustStore(t)
|
||||
cwd := t.TempDir()
|
||||
writeProjectMCPConfig(t, cwd)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatal("mcp check must fail closed on a trust-store read error")
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "could not be read") {
|
||||
t.Fatalf("mcp check store-error must emit the store-errored notice, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError documents accepted behavior: in an
|
||||
// untrusted workspace with project MCP config on disk, a login that fails because the
|
||||
// named USER-config server is not OAuth still co-emits the trust notice (project MCP was
|
||||
// excluded this run, which is true). The notice is advisory and keyed on "project config
|
||||
// was dropped", not on "this server was the dropped one" — the same coarse scoping the
|
||||
// mcp-check path uses. If this behavior changes intentionally, update this test.
|
||||
func TestRunMCPOAuthLoginNoticeCoFiresWithNonOAuthError(t *testing.T) {
|
||||
setTrustConfigRoot(t) // untrusted
|
||||
cwd := t.TempDir()
|
||||
writeProjectMCPConfig(t, cwd)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
// "foo" is a user-config server (survives the gate) that does not declare oauth.
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"foo": {Type: "http", URL: "https://foo.invalid"},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "oauth", "login", "foo"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatal("login on a non-oauth server must fail")
|
||||
}
|
||||
got := errBuf.String()
|
||||
if !strings.Contains(got, "oauth") {
|
||||
t.Fatalf("want the not-oauth error, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "zero trust") {
|
||||
t.Fatalf("expected the advisory trust notice to co-fire (accepted behavior), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerResolveConfigErrorPropagates covers the resolveMCPConfig-error
|
||||
// return of the gated resolver: the error propagates with the computed skip.
|
||||
func TestResolveOAuthServerResolveConfigErrorPropagates(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{}, errors.New("resolve boom")
|
||||
},
|
||||
}
|
||||
_, _, err := resolveOAuthServer(deps, "remote")
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve boom") {
|
||||
t.Fatalf("resolveMCPConfig error must propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOAuthServerNormalizeErrorPropagates covers the NormalizeConfig-error return:
|
||||
// a malformed server (stdio with no command) fails normalization.
|
||||
func TestResolveOAuthServerNormalizeErrorPropagates(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "stdio"}, // no command -> NormalizeConfig rejects
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
_, _, err := resolveOAuthServer(deps, "remote")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires command") {
|
||||
t.Fatalf("NormalizeConfig error must propagate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckDisabledServer covers the found-but-disabled branch of runMCPCheck.
|
||||
func TestRunMCPCheckDisabledServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"proj": {Type: "stdio", Command: "c", Disabled: true},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatal("mcp check on a disabled server must fail")
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "is disabled") {
|
||||
t.Fatalf("want disabled error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunMCPCheckNormalizeError covers the found-but-malformed branch of runMCPCheck: the
|
||||
// scoped NormalizeConfig fails before any spawn.
|
||||
func TestRunMCPCheckNormalizeError(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return t.TempDir(), nil },
|
||||
resolveMCPConfig: func(_ string, _ bool) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"proj": {Type: "stdio"}, // no command
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatal("mcp check on a malformed server must fail")
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "requires command") {
|
||||
t.Fatalf("want normalize error, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// mcpTrustDeps builds an appDeps whose resolveMCPConfig HONORS excludeProject: it
|
||||
// returns a project stdio server only when excludeProject is false, mirroring the
|
||||
// real ResolveMCP gate. It records the excludeProject value it was called with and
|
||||
// whether registerMCPTools (which spawns servers) actually fired.
|
||||
func mcpTrustDeps(gotExclude *bool, spawned *bool) appDeps {
|
||||
return appDeps{
|
||||
resolveMCPConfig: func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
*gotExclude = excludeProject
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["proj-srv"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
registerMCPTools: func(_ context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
*spawned = true
|
||||
return closeFunc(func() error { return nil }), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPGateUntrustedExcludesProjectServer proves the P0 fix: an untrusted trustRoot
|
||||
// makes registerMCPToolsForWorkspace resolve MCP config with excludeProject=true and,
|
||||
// because that drops the project server, it never spawns anything. This test is
|
||||
// load-bearing: if the gate is removed (a hardcoded excludeProject=false passed to
|
||||
// resolveMCPConfig), gotExclude is false, the project server survives, spawned flips
|
||||
// true, and every assertion below fails.
|
||||
func TestMCPGateUntrustedExcludesProjectServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir() // never trusted
|
||||
|
||||
var gotExclude, spawned bool
|
||||
deps := mcpTrustDeps(&gotExclude, &spawned)
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
|
||||
}
|
||||
defer func() { _ = runtime.Close() }()
|
||||
|
||||
if !gotExclude {
|
||||
t.Fatalf("untrusted workspace must resolve MCP config with excludeProject=true")
|
||||
}
|
||||
if spawned {
|
||||
t.Fatalf("untrusted workspace must not spawn the project MCP server")
|
||||
}
|
||||
if _, ok := runtime.(noopMCPRuntime); !ok {
|
||||
t.Fatalf("with the project server dropped, the runtime should be the noop runtime, got %T", runtime)
|
||||
}
|
||||
// This repo has no ./.zero/config.json, so there is no project MCP config to
|
||||
// notice about even though it is untrusted; the skip must stay clean.
|
||||
if skip.excludedProjectConfig {
|
||||
t.Fatalf("no project MCP config on disk, so the skip must not flag an excluded config")
|
||||
}
|
||||
if skip.trustCheckErrored {
|
||||
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPGateEmptyTrustRootFailsClosed proves fail-closed-by-construction: a caller
|
||||
// that forgot to resolve trustRoot (empty) still excludes the project layer.
|
||||
func TestMCPGateEmptyTrustRootFailsClosed(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
// Even trusting the repo must not help when the caller passes an empty root.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, spawned bool
|
||||
deps := mcpTrustDeps(&gotExclude, &spawned)
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, "")
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
|
||||
}
|
||||
defer func() { _ = runtime.Close() }()
|
||||
|
||||
if !gotExclude {
|
||||
t.Fatalf("empty trustRoot must fail closed (excludeProject=true)")
|
||||
}
|
||||
if spawned {
|
||||
t.Fatalf("empty trustRoot must not spawn the project MCP server")
|
||||
}
|
||||
// Empty trustRoot is a clean fail-closed verdict, not a store-read error.
|
||||
if skip.trustCheckErrored {
|
||||
t.Fatalf("empty trustRoot is not a store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPGateTrustedSpawnsProjectServer proves R3 for MCP: after Trust(repo) the
|
||||
// project layer is included (excludeProject=false) and the project server spawns.
|
||||
func TestMCPGateTrustedSpawnsProjectServer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, spawned bool
|
||||
deps := mcpTrustDeps(&gotExclude, &spawned)
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
|
||||
}
|
||||
defer func() { _ = runtime.Close() }()
|
||||
|
||||
if gotExclude {
|
||||
t.Fatalf("trusted workspace must resolve MCP config with excludeProject=false")
|
||||
}
|
||||
if !spawned {
|
||||
t.Fatalf("trusted workspace must spawn the project MCP server")
|
||||
}
|
||||
if skip.excludedProjectConfig {
|
||||
t.Fatalf("trusted workspace must not report the project MCP layer excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPToolsListSurfacesTrustNotice proves `zero mcp tools list` no longer drops the
|
||||
// project MCP layer silently in an untrusted workspace: the gated skip is surfaced on
|
||||
// stderr (the list stays on stdout), so an empty list is explained rather than read as
|
||||
// "nothing configured". Trusting the repo silences it.
|
||||
func TestMCPToolsListSurfacesTrustNotice(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := func() string {
|
||||
var out, errBuf bytes.Buffer
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return repo, nil },
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
}
|
||||
if code := runWithDeps([]string{"mcp", "tools", "list"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("mcp tools list exit = %d, stderr=%q", code, errBuf.String())
|
||||
}
|
||||
return errBuf.String()
|
||||
}
|
||||
|
||||
// Untrusted: the gated project MCP layer must be explained on stderr.
|
||||
if errUntrusted := run(); !strings.Contains(errUntrusted, "MCP servers") || !strings.Contains(errUntrusted, "zero trust") {
|
||||
t.Fatalf("untrusted `mcp tools list` must surface the trust notice on stderr, got %q", errUntrusted)
|
||||
}
|
||||
|
||||
// Trusted: nothing is skipped, so no notice.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if errTrusted := run(); strings.Contains(errTrusted, "ignoring project") {
|
||||
t.Fatalf("trusted `mcp tools list` must not emit a trust notice, got %q", errTrusted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectMCPConfigExists exercises every branch of the notice-gating detector:
|
||||
// only a ./.zero/config.json that parses AND declares at least one server is true.
|
||||
func TestProjectMCPConfigExists(t *testing.T) {
|
||||
writeCfg := func(t *testing.T, dir, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if projectMCPConfigExists("") {
|
||||
t.Fatal("empty workspace root must be false")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string // "" means write no config.json at all
|
||||
want bool
|
||||
}{
|
||||
{"no file", "", false},
|
||||
{"declares a server", `{"mcp":{"servers":{"a":{"type":"stdio","command":"x"}}}}`, true},
|
||||
{"empty servers map", `{"mcp":{"servers":{}}}`, false},
|
||||
{"config without mcp key", `{"model":"x"}`, false},
|
||||
{"unparseable json", `{not valid`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tc.body != "" {
|
||||
writeCfg(t, dir, tc.body)
|
||||
}
|
||||
if got := projectMCPConfigExists(dir); got != tc.want {
|
||||
t.Fatalf("projectMCPConfigExists = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPGateFailClosedOnStoreError proves the MCP surface reports a store-read error
|
||||
// (trust.json created as a directory) as trustCheckErrored, so the caller's notice can
|
||||
// name the fail-closed reason -- the same error path the hook and plugin gates cover.
|
||||
func TestMCPGateFailClosedOnStoreError(t *testing.T) {
|
||||
configRoot := setTrustConfigRoot(t)
|
||||
trustPath := filepath.Join(configRoot, "zero", "trust.json")
|
||||
if err := os.MkdirAll(trustPath, 0o700); err != nil {
|
||||
t.Fatalf("create trust.json as a directory: %v", err)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir project .zero: %v", err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write project config.json: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, spawned bool
|
||||
deps := mcpTrustDeps(&gotExclude, &spawned)
|
||||
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace: %v", err)
|
||||
}
|
||||
defer func() { _ = runtime.Close() }()
|
||||
if !gotExclude {
|
||||
t.Fatalf("a store-read error must fail closed (excludeProject=true)")
|
||||
}
|
||||
if spawned {
|
||||
t.Fatalf("a store-read error must not spawn the project MCP server")
|
||||
}
|
||||
if !skip.trustCheckErrored {
|
||||
t.Fatalf("skip must mark the store-read error so the notice can name it")
|
||||
}
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatalf("the project MCP layer must be reported excluded on the error path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPGateUntrustedNoticesProjectMCPConfig proves the notice-surfacing fix: when an
|
||||
// untrusted workspace actually declares project MCP servers in ./.zero/config.json,
|
||||
// registerMCPToolsForWorkspace reports excludedProjectConfig=true so the caller can
|
||||
// warn (the CodeRabbit finding: project MCP was gated silently). Trusting the repo
|
||||
// clears the skip.
|
||||
func TestMCPGateUntrustedNoticesProjectMCPConfig(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
// A real project MCP config on disk: projectMCPConfigExists reads this file.
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir project .zero: %v", err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write project config.json: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude, spawned bool
|
||||
deps := mcpTrustDeps(&gotExclude, &spawned)
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
// Untrusted: the project MCP layer is dropped AND flagged for the notice.
|
||||
runtime, skip, err := registerMCPToolsForWorkspace(context.Background(), repo, registry, deps, mcp.AutonomyLow, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace (untrusted): %v", err)
|
||||
}
|
||||
defer func() { _ = runtime.Close() }()
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatalf("untrusted workspace with project MCP config must flag excludedProjectConfig for the notice")
|
||||
}
|
||||
if skip.trustCheckErrored {
|
||||
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
||||
}
|
||||
|
||||
// Trusted: nothing is skipped, so no notice.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
_, trustedSkip, err := registerMCPToolsForWorkspace(context.Background(), repo, tools.NewRegistry(), deps, mcp.AutonomyLow, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("registerMCPToolsForWorkspace (trusted): %v", err)
|
||||
}
|
||||
if trustedSkip.excludedProjectConfig {
|
||||
t.Fatalf("trusted workspace must not flag an excluded project MCP layer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPCheckSurfacesTrustNotice proves the `zero mcp check` notice fix: in an untrusted
|
||||
// workspace whose only definition of a server lives in ./.zero/config.json, the gate
|
||||
// drops the server and the command must emit the one-line trust notice before the
|
||||
// "not configured" error, instead of a bare miss that hides the trust exclusion. The R4
|
||||
// half proves the notice self-gates: a genuinely-absent server in a workspace with no
|
||||
// project config on disk prints "not configured" with no notice.
|
||||
func TestMCPCheckSurfacesTrustNotice(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
|
||||
// resolveMCPConfig fake that HONORS excludeProject: `proj` survives only when the
|
||||
// project layer is included, mirroring the real gate.
|
||||
dropFake := func(_ string, excludeProject bool) (config.MCPConfig, error) {
|
||||
servers := map[string]config.MCPServerConfig{}
|
||||
if !excludeProject {
|
||||
servers["proj"] = config.MCPServerConfig{Type: "stdio", Command: "proj-cmd"}
|
||||
}
|
||||
return config.MCPConfig{Servers: servers}, nil
|
||||
}
|
||||
|
||||
// Untrusted workspace whose ONLY definition of `proj` is project config on disk.
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
deps := appDeps{getwd: func() (string, error) { return repo, nil }, resolveMCPConfig: dropFake}
|
||||
if code := runWithDeps([]string{"mcp", "check", "proj"}, &out, &errBuf, deps); code == exitSuccess {
|
||||
t.Fatalf("mcp check on a gated project server must fail, got success; stderr=%q", errBuf.String())
|
||||
}
|
||||
if got := errBuf.String(); !strings.Contains(got, "not configured") ||
|
||||
!strings.Contains(got, "MCP servers") || !strings.Contains(got, "zero trust") {
|
||||
t.Fatalf("untrusted mcp check must report not-configured AND surface the trust notice, got %q", got)
|
||||
}
|
||||
|
||||
// R4: an untrusted workspace with NO project MCP config on disk has nothing to
|
||||
// notice about, so an absent server prints "not configured" with no trust notice.
|
||||
bare := t.TempDir()
|
||||
var out2, errBuf2 bytes.Buffer
|
||||
deps2 := appDeps{getwd: func() (string, error) { return bare, nil }, resolveMCPConfig: dropFake}
|
||||
if code := runWithDeps([]string{"mcp", "check", "ghost"}, &out2, &errBuf2, deps2); code == exitSuccess {
|
||||
t.Fatalf("mcp check on an absent server must fail")
|
||||
}
|
||||
if got := errBuf2.String(); !strings.Contains(got, "not configured") {
|
||||
t.Fatalf("stderr must report not configured, got %q", got)
|
||||
}
|
||||
if got := errBuf2.String(); strings.Contains(got, "ignoring project") || strings.Contains(got, "zero trust") {
|
||||
t.Fatalf("no project config on disk means no trust notice, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -17,13 +15,9 @@ import (
|
||||
// later dispatcher + skill wiring can consume it: the plugin hook definitions and
|
||||
// the plugin skill search roots. The zero value (no plugins) is inert — the
|
||||
// dispatcher gets no extra hooks and the skill tool keeps only the default dir.
|
||||
// The embedded trustSkip reports whether the project plugin layer was dropped for
|
||||
// an untrusted workspace, so the caller can fold it into the single combined
|
||||
// trust notice.
|
||||
type pluginActivation struct {
|
||||
hooks []hooks.Definition
|
||||
skillRoots []string
|
||||
trustSkip
|
||||
}
|
||||
|
||||
// activatePlugins loads the workspace's plugins and makes their declared
|
||||
@@ -33,26 +27,12 @@ type pluginActivation struct {
|
||||
//
|
||||
// It fails OPEN: any load error (or a malformed plugin) is surfaced as a warning
|
||||
// on stderr and otherwise skipped, so a broken plugin can never wedge startup —
|
||||
// mirroring how newHookDispatcher and skills.Load tolerate bad input. That
|
||||
// fail-OPEN handling of MALFORMED plugins is separate from the trust gate below.
|
||||
//
|
||||
// The trust check lives here, inside the chokepoint, so no caller can bypass it:
|
||||
// the project plugin root is dropped (ExcludeProject) whenever trustRoot is empty,
|
||||
// the trust store cannot be read, or the workspace is not trusted (fail-closed).
|
||||
// trustRoot is the original launch directory (resolved before any --worktree
|
||||
// reassignment). The returned activation carries the skip report so the caller can
|
||||
// emit one combined notice; the notice itself is NOT emitted here.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer, trustRoot string) pluginActivation {
|
||||
excludeProject, trustCheckErrored := resolveTrust(trustRoot)
|
||||
skip := trustSkip{
|
||||
excludedProjectConfig: excludeProject && projectPluginsDirExists(workspaceRoot),
|
||||
trustCheckErrored: trustCheckErrored,
|
||||
}
|
||||
|
||||
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot, ExcludeProject: excludeProject})
|
||||
// mirroring how newHookDispatcher and skills.Load tolerate bad input.
|
||||
func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDeps, stderr io.Writer) pluginActivation {
|
||||
loaded, err := deps.loadPlugins(plugins.LoadOptions{Cwd: workspaceRoot})
|
||||
if err != nil {
|
||||
writePluginActivationWarning(stderr, "failed to load plugins: "+err.Error())
|
||||
return pluginActivation{trustSkip: skip}
|
||||
return pluginActivation{}
|
||||
}
|
||||
|
||||
// Load fails OPEN per plugin: a malformed manifest is recorded as a diagnostic
|
||||
@@ -76,18 +56,7 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
registry.Register(plugins.NewSkillTool(deps.skillsDir(), result.SkillRoots))
|
||||
}
|
||||
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots, trustSkip: skip}
|
||||
}
|
||||
|
||||
// projectPluginsDirExists reports whether a ./.zero/plugins directory is present
|
||||
// under workspaceRoot, so the caller only notices about config it actually
|
||||
// skipped.
|
||||
func projectPluginsDirExists(workspaceRoot string) bool {
|
||||
if workspaceRoot == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(workspaceRoot, ".zero", "plugins"))
|
||||
return err == nil && info.IsDir()
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||
}
|
||||
|
||||
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||
|
||||
@@ -51,8 +51,7 @@ func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
|
||||
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
||||
|
||||
if _, ok := registry.Get("demo_lookup"); !ok {
|
||||
t.Fatalf("plugin tool not registered into the bootstrap registry")
|
||||
@@ -91,8 +90,7 @@ func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewSkillTool(t.TempDir())) // core skill tool, like the real bootstrap
|
||||
var stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, fakePluginDeps(t, loaded), &stderr, workspace)
|
||||
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
||||
|
||||
if len(activation.skillRoots) != 1 {
|
||||
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
|
||||
@@ -126,8 +124,7 @@ func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
activatePlugins(workspace, registry, deps, &stderr, workspace)
|
||||
activatePlugins(t.TempDir(), registry, deps, &stderr)
|
||||
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("a load diagnostic must be surfaced on stderr")
|
||||
@@ -146,8 +143,7 @@ func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
activation := activatePlugins(workspace, registry, deps, &stderr, workspace)
|
||||
activation := activatePlugins(t.TempDir(), registry, deps, &stderr)
|
||||
|
||||
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
|
||||
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
|
||||
@@ -177,8 +173,7 @@ func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
workspace := t.TempDir()
|
||||
activatePlugins(workspace, registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr, workspace)
|
||||
activatePlugins(t.TempDir(), registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr)
|
||||
|
||||
if _, ok := registry.Get("good_tool"); !ok {
|
||||
t.Fatal("the good plugin tool must still register despite a malformed sibling")
|
||||
@@ -203,7 +198,7 @@ func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
|
||||
Enabled: true,
|
||||
}}
|
||||
|
||||
dispatcher, _ := newHookDispatcherWithExtra(workspace, extra, workspace)
|
||||
dispatcher := newHookDispatcherWithExtra(workspace, extra)
|
||||
if dispatcher == nil {
|
||||
t.Fatal("dispatcher should never be nil for a clean workspace")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
@@ -52,19 +51,6 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
|
||||
// Apply provider-specific model filtering for catalog-backed profiles.
|
||||
// For example, opencode-go-anthropic-compatible only permits Qwen and
|
||||
// MiniMax model IDs; the raw /zen/go/v1/models endpoint returns many more.
|
||||
if profile.CatalogID != "" {
|
||||
filtered := make([]providermodeldiscovery.Model, 0, len(models))
|
||||
for _, model := range models {
|
||||
if providermodelcatalog.ModelIDAllowedForProvider(profile.CatalogID, model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
models = filtered
|
||||
}
|
||||
|
||||
if options.json {
|
||||
items := make([]map[string]any, 0, len(models))
|
||||
for _, model := range models {
|
||||
|
||||
@@ -132,62 +132,6 @@ func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsFiltersForCatalogProfile(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
// Override the resolved config to include a catalog-backed profile.
|
||||
deps.resolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
BaseURL: "https://api.example.com/zen/go/v1",
|
||||
APIKey: "sk-test",
|
||||
Model: "minimax-max",
|
||||
}
|
||||
return config.ResolvedConfig{
|
||||
ActiveProvider: "opencode-go",
|
||||
Providers: []config.ProviderProfile{profile},
|
||||
Provider: profile,
|
||||
}, nil
|
||||
}
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "deepseek-chat", Description: "DeepSeek Chat"},
|
||||
{ID: "qwen-max", Description: "Qwen Max"},
|
||||
{ID: "minimax-max-01", Description: "MiniMax Max"},
|
||||
{ID: "kimi-k2", Description: "Kimi K2"},
|
||||
{ID: "glm-4", Description: "GLM-4"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
// Only Qwen and MiniMax model IDs should survive the filter.
|
||||
if strings.Contains(out, "deepseek-chat") {
|
||||
t.Error("output should not contain deepseek-chat (blocked by opencode-go-anthropic filter)")
|
||||
}
|
||||
if strings.Contains(out, "kimi-k2") {
|
||||
t.Error("output should not contain kimi-k2 (blocked by opencode-go-anthropic filter)")
|
||||
}
|
||||
if strings.Contains(out, "glm-4") {
|
||||
t.Error("output should not contain glm-4 (blocked by opencode-go-anthropic filter)")
|
||||
}
|
||||
for _, want := range []string{"qwen-max", "minimax-max-01"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing allowed model %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "2 models discovered") {
|
||||
t.Fatalf("output should show 2 models:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -307,143 +306,3 @@ func providerCommandArg(value string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type providerNamesOptions struct {
|
||||
names []string
|
||||
json bool
|
||||
}
|
||||
|
||||
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
|
||||
options := providerNamesOptions{}
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
options.names = append(options.names, arg)
|
||||
}
|
||||
}
|
||||
if len(options.names) != want {
|
||||
return options, false, execUsageError{usage}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
// runProvidersRemove deletes a saved provider profile and its stored API key.
|
||||
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
|
||||
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
|
||||
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
name := options.names[0]
|
||||
cfg, err := config.RemoveProvider(configPath, name)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
// Delete the key from the store BESIDE the config being edited — the same
|
||||
// store setup/rename write to — not the default-path store, so a
|
||||
// non-default config path cannot leave the encrypted key behind.
|
||||
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
|
||||
if options.json {
|
||||
payload := map[string]any{
|
||||
"removed": name,
|
||||
"keyRemoved": keyRemoved,
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}
|
||||
if keyErr != nil {
|
||||
// A lingering secret must not read as a clean removal.
|
||||
payload["keyError"] = keyErr.Error()
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if keyErr != nil {
|
||||
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else if keyRemoved {
|
||||
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// removeStoredProviderKeyAt deletes a provider's API key from the credential
|
||||
// store co-located with configPath (the store SecureProviderProfile captured
|
||||
// it into and RenameProvider migrates within).
|
||||
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
|
||||
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return store.Delete(provider)
|
||||
}
|
||||
|
||||
// runProvidersRename renames a saved provider profile, migrating its stored
|
||||
// API key and the activeProvider pointer along with it (config.RenameProvider).
|
||||
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
configPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, map[string]any{
|
||||
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
|
||||
"activeProvider": cfg.ActiveProvider,
|
||||
"configPath": configPath,
|
||||
}); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -234,48 +234,3 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
|
||||
// from the credential store CO-LOCATED with the config being edited (where
|
||||
// SecureProviderProfile captured it), not the default-path store.
|
||||
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
|
||||
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
store, err := config.ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("gw", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed key: %v", err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
|
||||
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
|
||||
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Removed string `json:"removed"`
|
||||
KeyRemoved bool `json:"keyRemoved"`
|
||||
KeyError string `json:"keyError"`
|
||||
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.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
if payload.ActiveProvider != "other" {
|
||||
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
|
||||
}
|
||||
if _, ok, _ := store.Get("gw"); ok {
|
||||
t.Fatalf("stored key must be deleted from the store beside the config")
|
||||
}
|
||||
}
|
||||
@@ -457,9 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
|
||||
}
|
||||
|
||||
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
|
||||
// keyless token-login profile must pass the readiness check instead of being
|
||||
// told to set an API key it will never have.
|
||||
hasCredential := providerProfileHasCredential(profile)
|
||||
if profile.CatalogID != "" {
|
||||
descriptor, err := providercatalog.Require(profile.CatalogID)
|
||||
if err != nil {
|
||||
@@ -468,19 +466,23 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
if !providercatalog.RuntimeSupported(descriptor) {
|
||||
return fmt.Errorf("provider %q uses transport %q: %s", descriptor.ID, descriptor.Transport, providercatalog.RuntimeUnsupportedReason(descriptor))
|
||||
}
|
||||
}
|
||||
if providerHasOAuthLogin(profile, oauthLoggedInProviders()) {
|
||||
if descriptor.RequiresAuth && !hasCredential {
|
||||
apiKeyEnv := strings.TrimSpace(profile.APIKeyEnv)
|
||||
if apiKeyEnv == "" && len(descriptor.AuthEnvVars) > 0 {
|
||||
apiKeyEnv = descriptor.AuthEnvVars[0]
|
||||
}
|
||||
if apiKeyEnv != "" {
|
||||
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, apiKeyEnv)
|
||||
}
|
||||
return fmt.Errorf("provider %s requires API key", profile.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Delegates to the single source of truth shared with the TUI and the
|
||||
// setup wizard, so `providers check` never disagrees with them about
|
||||
// whether a saved provider (including a no-auth custom endpoint) is
|
||||
// actually missing a credential.
|
||||
if envVar, missing := profile.MissingCredentialEnv(); missing {
|
||||
if envVar != "" {
|
||||
return fmt.Errorf("provider %s requires API key; set %s", profile.Name, envVar)
|
||||
switch profile.ProviderKind {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindAnthropic, config.ProviderKindGoogle:
|
||||
if !hasCredential {
|
||||
return fmt.Errorf("provider %s requires API key", profile.Name)
|
||||
}
|
||||
return fmt.Errorf("provider %s requires API key", profile.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// Regression for issue #555's follow-up: `zero providers check` must not
|
||||
// error that a no-auth custom endpoint requires an API key, matching what
|
||||
// /model and /providers already treat as usable.
|
||||
func TestValidateProviderRuntimeReadyCustomEndpoint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "custom openai compatible with no credential configured",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
Model: "custom-model",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "custom openai compatible with stale legacy default env",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
Model: "custom-model",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "custom openai compatible with explicit non-default env still requires it",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
APIKeyEnv: "LLAMA_CPP_API_KEY",
|
||||
Model: "custom-model",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "catalog provider missing key still errors",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "groq",
|
||||
CatalogID: "groq",
|
||||
Model: "llama-3.3-70b-versatile",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := validateProviderRuntimeReady(c.profile)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("validateProviderRuntimeReady() error = %v, wantErr %v", err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type scratchFileBaseline map[string]bool
|
||||
|
||||
// scratchFileSnapshot records the untracked scratch-like files that already
|
||||
// existed before a run starts. Completion warnings compare against this baseline
|
||||
// so Zero only reports files newly left behind by the run, including files made
|
||||
// by shell commands that bypass the file tools.
|
||||
func scratchFileSnapshot(workspaceRoot string) scratchFileBaseline {
|
||||
untracked, ok := gitUntrackedFiles(workspaceRoot)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
baseline := make(scratchFileBaseline, len(untracked))
|
||||
for _, path := range untracked {
|
||||
if scratchLikePath(path) {
|
||||
baseline[path] = true
|
||||
}
|
||||
}
|
||||
return baseline
|
||||
}
|
||||
|
||||
// scratchFileWarning builds a completion-time warning listing scratch/debug-like
|
||||
// files that became untracked during this run. It deliberately does not warn for
|
||||
// every new untracked file: brand-new deliverables are a normal coding-task
|
||||
// result, while names such as `_debug.py`, `_fix_test.py`, or `scratch.js` are
|
||||
// the class of throwaway artifacts called out in issue #551.
|
||||
//
|
||||
// Returns "" when there is nothing to report: git is unavailable,
|
||||
// workspaceRoot isn't a git repo, no baseline was captured, or no new
|
||||
// scratch-like untracked files remain.
|
||||
func scratchFileWarning(workspaceRoot string, baseline scratchFileBaseline) string {
|
||||
if baseline == nil {
|
||||
return ""
|
||||
}
|
||||
untracked, ok := gitUntrackedFiles(workspaceRoot)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var scratchFiles []string
|
||||
for _, path := range untracked {
|
||||
if baseline[path] || !scratchLikePath(path) {
|
||||
continue
|
||||
}
|
||||
baseline[path] = true
|
||||
scratchFiles = append(scratchFiles, path)
|
||||
}
|
||||
if len(scratchFiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
displayPaths := make([]string, len(scratchFiles))
|
||||
for i, path := range scratchFiles {
|
||||
displayPaths[i] = filepath.ToSlash(path)
|
||||
}
|
||||
plural := "s"
|
||||
if len(displayPaths) == 1 {
|
||||
plural = ""
|
||||
}
|
||||
return "This run left " + strconv.Itoa(len(displayPaths)) + " new scratch/debug-like file" + plural + " untracked in git: " +
|
||||
strings.Join(displayPaths, ", ") + ". Review before committing/staging."
|
||||
}
|
||||
|
||||
func gitUntrackedFiles(workspaceRoot string) ([]string, bool) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
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.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var untracked []string
|
||||
for _, entry := range strings.Split(string(output), "\x00") {
|
||||
if !strings.HasPrefix(entry, "?? ") {
|
||||
continue
|
||||
}
|
||||
relative := strings.TrimPrefix(entry, "?? ")
|
||||
untracked = append(untracked, relative)
|
||||
}
|
||||
return untracked, true
|
||||
}
|
||||
|
||||
func scratchLikePath(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(filepath.ToSlash(path)))
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(name, "scratch") || strings.HasPrefix(name, "debug") || strings.HasPrefix(name, "tmp") || strings.HasPrefix(name, "temp") || strings.HasPrefix(name, "repro") {
|
||||
return true
|
||||
}
|
||||
for _, marker := range []string{"scratch", "debug", "tmp", "temp", "repro", "fix_test"} {
|
||||
if strings.Contains(name, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func runGitForScratchTest(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=test", "[email protected]",
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningFlagsUntrackedCreatedFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, root, "add", "README.md")
|
||||
runGitForScratchTest(t, root, "commit", "-m", "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
|
||||
scratchPath := filepath.Join(root, "_debug file.py")
|
||||
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
warning := scratchFileWarning(root, baseline)
|
||||
if warning == "" {
|
||||
t.Fatal("expected a warning about the untracked scratch file")
|
||||
}
|
||||
if !strings.Contains(warning, "_debug file.py") {
|
||||
t.Fatalf("expected warning to mention _debug file.py, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningDetectsShellCreatedScratchFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, root, "add", "README.md")
|
||||
runGitForScratchTest(t, root, "commit", "-m", "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
|
||||
// Simulate a shell heredoc/redirect creating a scratch file. The warning is
|
||||
// based on the git before/after state, not only FileTracker-created paths.
|
||||
if err := os.WriteFile(filepath.Join(root, "_fix_test.py"), []byte("print('debug')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
warning := scratchFileWarning(root, baseline)
|
||||
if !strings.Contains(warning, "_fix_test.py") {
|
||||
t.Fatalf("expected warning to mention shell-created _fix_test.py, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentForNormalDeliverableFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, root, "add", "README.md")
|
||||
runGitForScratchTest(t, root, "commit", "-m", "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "app.py"), []byte("print('app')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning for a normal new deliverable file, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentForUnderscoreDeliverableFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, root, "add", "README.md")
|
||||
runGitForScratchTest(t, root, "commit", "-m", "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "_generated.go"), []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning for an underscore-prefixed deliverable file, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentWhenFileIsTracked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
trackedPath := filepath.Join(root, "_debug.py")
|
||||
if err := os.WriteFile(trackedPath, []byte("print('app')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGitForScratchTest(t, root, "add", "_debug.py")
|
||||
runGitForScratchTest(t, root, "commit", "-m", "init")
|
||||
|
||||
// A scratch-like file subsequently staged/committed by the
|
||||
// model itself is no longer a loose scratch file, so no warning.
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning for a tracked file, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentWhenNoFilesCreated(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning when nothing was created, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentWhenFileWasRemovedAgain(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
baseline := scratchFileSnapshot(root)
|
||||
scratchPath := filepath.Join(root, "_debug.py")
|
||||
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(scratchPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The model created and then cleaned up its own scratch file — nothing to warn about.
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning for a removed file, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentForPreexistingScratchFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runGitForScratchTest(t, root, "init")
|
||||
if err := os.WriteFile(filepath.Join(root, "_debug.py"), []byte("print('debug')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseline := scratchFileSnapshot(root)
|
||||
|
||||
if warning := scratchFileWarning(root, baseline); warning != "" {
|
||||
t.Fatalf("expected no warning for a pre-existing scratch file, got %q", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchFileWarningSilentWhenNotAGitRepo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scratchPath := filepath.Join(root, "_debug.py")
|
||||
if err := os.WriteFile(scratchPath, []byte("print('debug')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if warning := scratchFileWarning(root, scratchFileSnapshot(root)); warning != "" {
|
||||
t.Fatalf("expected no warning outside a git repo, got %q", warning)
|
||||
}
|
||||
}
|
||||
+41
-21
@@ -322,9 +322,7 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
// the candidate set is permissive (profile name + catalog ID).
|
||||
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
||||
for _, name := range profile.OAuthLoginCandidates() {
|
||||
// Store keys are normalized to lower case (oauth.ProviderKey), so the
|
||||
// presence check must compare the same way.
|
||||
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
|
||||
if oauthLogins[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -346,19 +344,18 @@ func oauthLoggedInProviders() map[string]bool {
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.HasToken {
|
||||
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
|
||||
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstUsableProvider returns the saved provider best suited to run without
|
||||
// onboarding: the first usable (inline credential, stored OAuth login, or
|
||||
// no-auth/local) non-local provider, else the first usable local one. It lets
|
||||
// the CLI fall back to an already-configured login when the active provider
|
||||
// happens to lack a credential, instead of re-running onboarding every launch.
|
||||
// onboarding: the first usable (inline credential present, or no-auth/local)
|
||||
// non-local provider, else the first usable local one. It lets the CLI fall back
|
||||
// to an already-configured login when the active provider happens to lack a
|
||||
// credential, instead of re-running onboarding every launch.
|
||||
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
|
||||
logins := oauthLoggedInProviders()
|
||||
var localFallback config.ProviderProfile
|
||||
haveLocal := false
|
||||
for _, profile := range providers {
|
||||
@@ -374,11 +371,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
|
||||
continue
|
||||
}
|
||||
}
|
||||
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
||||
// when the profile has no inline key / env var — mirrors setupRequired and
|
||||
// usableSavedProviders so this fallback doesn't force onboarding for a
|
||||
// provider the user is already authenticated with.
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing {
|
||||
continue
|
||||
}
|
||||
if providerProfileIsLocal(profile) {
|
||||
@@ -503,14 +496,41 @@ func setupTryThisExample(profile config.ProviderProfile) string {
|
||||
return example
|
||||
}
|
||||
|
||||
// setupMissingCredentialEnv reports the environment variable a saved profile
|
||||
// is expected to read an API key from, and whether that expectation is
|
||||
// unmet. It delegates to config.ProviderProfile.MissingCredentialEnv, the
|
||||
// single source of truth shared with the TUI's provider status/wizard
|
||||
// surfaces and the CLI's `providers check` readiness gate, so all three never
|
||||
// disagree about whether a saved provider is missing a credential.
|
||||
func setupMissingCredentialEnv(profile config.ProviderProfile) (string, bool) {
|
||||
return profile.MissingCredentialEnv()
|
||||
if providerProfileHasCredential(profile) {
|
||||
return "", false
|
||||
}
|
||||
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
||||
descriptor, err := providercatalog.Require(catalogID)
|
||||
if err != nil || !descriptor.RequiresAuth {
|
||||
return "", false
|
||||
}
|
||||
return firstNonEmptyCLI(profile.APIKeyEnv, setupProviderEnvVar(descriptor)), true
|
||||
}
|
||||
|
||||
switch normalizedSetupProviderKind(profile) {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
||||
return firstNonEmptyCLI(profile.APIKeyEnv, "OPENAI_API_KEY"), true
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return firstNonEmptyCLI(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true
|
||||
case config.ProviderKindGoogle:
|
||||
return firstNonEmptyCLI(profile.APIKeyEnv, "GEMINI_API_KEY"), true
|
||||
default:
|
||||
if strings.TrimSpace(profile.APIKeyEnv) != "" {
|
||||
return strings.TrimSpace(profile.APIKeyEnv), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedSetupProviderKind(profile config.ProviderProfile) config.ProviderKind {
|
||||
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
|
||||
return config.ProviderKind(strings.ToLower(kind))
|
||||
}
|
||||
if provider := strings.TrimSpace(profile.Provider); provider != "" {
|
||||
return config.ProviderKind(strings.ToLower(provider))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setupCheckCommand(name string) string {
|
||||
|
||||
@@ -2,14 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
|
||||
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
|
||||
@@ -22,7 +19,6 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
|
||||
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
|
||||
@@ -34,7 +30,6 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFirstUsableProviderNoneUsable(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
@@ -47,10 +42,6 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
|
||||
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
|
||||
// credential, so it can serve as a fallback rather than forcing onboarding.
|
||||
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
|
||||
// Isolate the OAuth token store: on a developer machine with a real xai
|
||||
// login, the env-only xai profile would count as usable and win over the
|
||||
// local proxy this test is about.
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
|
||||
@@ -65,7 +56,6 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
|
||||
// BaseURL has no endpoint, so it must be skipped rather than selected as a
|
||||
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
|
||||
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
|
||||
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
|
||||
@@ -76,29 +66,6 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// An OAuth-only provider (no inline key, no env var) must be selectable as a
|
||||
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
|
||||
// authenticated user gets forced back into onboarding when activeProvider
|
||||
// goes stale.
|
||||
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "xai" {
|
||||
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -122,32 +89,3 @@ func TestProviderProfileIsLocal(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
|
||||
// whose credential is a stored OAuth login (the shape the login flows persist)
|
||||
// must be usable during active-pointer recovery — the same rule setupRequired
|
||||
// and usableSavedProviders apply — instead of falling through to onboarding.
|
||||
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
|
||||
}
|
||||
|
||||
// No login stored: still not usable.
|
||||
if got, ok := firstUsableProvider(providers); ok {
|
||||
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
|
||||
}
|
||||
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("oauth store: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
|
||||
t.Fatalf("save token: %v", err)
|
||||
}
|
||||
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "chatgpt" {
|
||||
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
@@ -64,54 +64,6 @@ func TestSetupMissingCredentialEnv(t *testing.T) {
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Regression for issue #555: a custom OpenAI-compatible profile saved
|
||||
// with no credential (e.g. a local llama.cpp server with no auth) must
|
||||
// not be reported as missing one, or it gets filtered out of /model on
|
||||
// the next run even though it was configured exactly as intended.
|
||||
name: "custom openai compatible with no credential configured",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "custom openai compatible with explicit api key env",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
APIKeyEnv: "LLAMA_CPP_API_KEY",
|
||||
},
|
||||
wantEnv: "LLAMA_CPP_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Self-heal regression: profiles saved by the pre-fix wizard were
|
||||
// stamped with the catalog's own guessed default (OPENAI_API_KEY)
|
||||
// even when the endpoint needed no auth. That stale value must be
|
||||
// treated the same as unset so already-broken saved profiles start
|
||||
// working again without a config.json rewrite.
|
||||
name: "custom openai compatible with stale legacy default env",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "custom anthropic compatible with no credential configured",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "local-proxy",
|
||||
CatalogID: "custom-anthropic-compatible",
|
||||
BaseURL: "http://localhost:9000/anthropic",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// runTrust implements `zero trust`, letting the user opt a workspace into running
|
||||
// its project-scoped executable config (hooks, plugins, MCP servers). Trust is
|
||||
// keyed on the exact normalized working directory, matching the exact-match trust
|
||||
// model and cwd-relative project-config discovery.
|
||||
//
|
||||
// zero trust trust the current working directory
|
||||
// zero trust list print the trusted roots, one per line
|
||||
// zero trust remove [path] untrust the current directory, or a named path
|
||||
func runTrust(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return trustCurrentDir(stdout, stderr, deps)
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return trustList(stdout, stderr)
|
||||
case "remove", "rm", "untrust":
|
||||
return trustRemove(args[1:], stdout, stderr, deps)
|
||||
case "-h", "--help", "help":
|
||||
// Explicit help is a success path: write usage to stdout and exit 0, matching
|
||||
// the other subcommands (mcp, sandbox, skills, cron, ...). Only the unknown-
|
||||
// subcommand error path below writes usage to stderr with a usage exit code.
|
||||
writeTrustUsage(stdout)
|
||||
return exitSuccess
|
||||
default:
|
||||
if _, err := fmt.Fprintf(stderr, "zero trust: unknown subcommand %q\n\n", args[0]); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
writeTrustUsage(stderr)
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// trustCurrentDir trusts the exact current working directory.
|
||||
func trustCurrentDir(stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
|
||||
}
|
||||
if err := workspacetrust.Trust(cwd); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Trusted %s\n", cwd); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// trustList prints each trusted root, one per line, or a friendly line when none
|
||||
// are trusted.
|
||||
func trustList(stdout io.Writer, stderr io.Writer) int {
|
||||
roots, err := workspacetrust.List()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
if _, err := fmt.Fprintln(stdout, "No trusted workspaces."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
for _, root := range roots {
|
||||
if _, err := fmt.Fprintln(stdout, root); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// trustRemove untrusts the current directory, or a named path when one is given.
|
||||
// The path argument is passed to workspacetrust.Untrust verbatim, which normalizes
|
||||
// it (filepath.Abs + filepath.EvalSymlinks) the same way the store does, so a
|
||||
// relative or trailing-slash argument still matches the stored entry.
|
||||
func trustRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
var target string
|
||||
switch len(args) {
|
||||
case 0:
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(fmt.Errorf("resolve workspace: %w", err), redaction.Options{}), exitCrash)
|
||||
}
|
||||
target = cwd
|
||||
case 1:
|
||||
target = args[0]
|
||||
default:
|
||||
if _, err := fmt.Fprintln(stderr, "usage: zero trust remove [path]"); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitUsage
|
||||
}
|
||||
if err := workspacetrust.Untrust(target); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Untrusted %s\n", target); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func writeTrustUsage(w io.Writer) {
|
||||
_, _ = fmt.Fprint(w, `Usage:
|
||||
zero trust Trust the current working directory
|
||||
zero trust list List trusted workspace roots
|
||||
zero trust remove [path] Untrust the current directory, or a named path
|
||||
|
||||
Trust lets Zero run a workspace's project-scoped hooks, plugins, and MCP
|
||||
servers (./.zero/hooks.json, ./.zero/plugins/, project MCP config). Trust is
|
||||
exact per directory.
|
||||
`)
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// These tests close the end-to-end gap the chokepoint unit tests leave open:
|
||||
// they drive the REAL agent loop (and, for the worktree cases, the real exec
|
||||
// entry point) with a fake provider that emits a tool call, so a project
|
||||
// beforeTool hook actually fires (or is gated away) through production code, not
|
||||
// a direct dispatcher call.
|
||||
|
||||
// markerTool is a minimal, always-allowed tool the fake provider "calls" so the
|
||||
// agent loop reaches dispatchBeforeTool (which only fires for a registered,
|
||||
// permitted tool). Its own Run is a no-op; the observable effect is the project
|
||||
// beforeTool hook the gate did or did not load.
|
||||
type markerTool struct{}
|
||||
|
||||
func (markerTool) Name() string { return "marker_tool" }
|
||||
func (markerTool) Description() string { return "test-only no-op tool" }
|
||||
func (markerTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (markerTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionAllow} }
|
||||
func (markerTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
// toolThenTextProvider calls toolName on the first turn, then answers with text so
|
||||
// the loop terminates. It detects "first turn" by the absence of a prior tool
|
||||
// result in the message history.
|
||||
type toolThenTextProvider struct{ toolName string }
|
||||
|
||||
func (p toolThenTextProvider) StreamCompletion(_ context.Context, req zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
toolAlreadyCalled := false
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleTool {
|
||||
toolAlreadyCalled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, 8)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
if toolAlreadyCalled {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
return
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: p.toolName}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"pattern":"*"}`}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// writeMarkerHook writes a ./.zero/hooks.json under dir whose enabled beforeTool
|
||||
// hook touches markerPath when it fires. The hook shells out to /bin/sh, so the
|
||||
// trusted-case assertion (the marker must appear) is meaningless on Windows, where
|
||||
// that interpreter does not exist; skip there, matching writeMarkerHookScript.
|
||||
func writeMarkerHook(t *testing.T, dir, markerPath string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("marker hook is POSIX-shell based (/bin/sh)")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".zero"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"enabled":true,"hooks":[{"id":"m","event":"beforeTool","command":"/bin/sh","args":["-c","touch '` + markerPath + `'"],"enabled":true}]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zero", "hooks.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustGateBlocksToolHookThroughAgentRun closes gap #2: the gate blocks a
|
||||
// real tool call's beforeTool hook through the production agent.Run loop, not
|
||||
// just a direct dispatcher call. Untrusted => the project hook is not in the
|
||||
// dispatcher, so the tool call fires nothing. Trusted => it fires.
|
||||
func TestTrustGateBlocksToolHookThroughAgentRun(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "marker")
|
||||
writeMarkerHook(t, repo, marker)
|
||||
|
||||
runOnce := func() {
|
||||
reg := tools.NewRegistry()
|
||||
reg.Register(markerTool{})
|
||||
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
|
||||
Registry: reg,
|
||||
Hooks: disp,
|
||||
PermissionMode: agent.PermissionModeUnsafe,
|
||||
MaxTurns: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("agent.Run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Untrusted: gate excludes the project layer; the beforeTool hook must not run.
|
||||
_ = os.Remove(marker)
|
||||
runOnce()
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("UNTRUSTED: project beforeTool hook ran through agent.Run (marker exists) -- gate failed OPEN")
|
||||
}
|
||||
|
||||
// Trusted: the hook is in the dispatcher; the tool call fires it.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
runOnce()
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("TRUSTED: project beforeTool hook did NOT run (marker absent): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrustGateFiresInDefaultAutoMode is the in-scope proof for the security
|
||||
// report: the beforeTool hook fires in the DEFAULT permission mode (auto), NOT
|
||||
// only under --skip-permissions-unsafe. marker_tool has PermissionAllow safety,
|
||||
// so effectivePermission grants it at loop.go without a prompt regardless of
|
||||
// mode; dispatchBeforeTool then runs the project hook. This means the
|
||||
// vulnerability is reachable in normal operation with the sandbox ON, so it is
|
||||
// not the "requires the user to disable the sandbox" out-of-scope case.
|
||||
//
|
||||
// Trusted => the hook fires in auto mode without any unsafe flag; untrusted =>
|
||||
// the gate blocks it, also in auto mode. No OnPermissionRequest is wired: an
|
||||
// auto-allowed tool needs no approval callback, which is the whole point.
|
||||
func TestTrustGateFiresInDefaultAutoMode(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "marker")
|
||||
writeMarkerHook(t, repo, marker)
|
||||
|
||||
runOnce := func(mode agent.PermissionMode) {
|
||||
reg := tools.NewRegistry()
|
||||
reg.Register(markerTool{})
|
||||
disp, _ := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
if _, err := agent.Run(context.Background(), "go", toolThenTextProvider{toolName: "marker_tool"}, agent.Options{
|
||||
Registry: reg,
|
||||
Hooks: disp,
|
||||
PermissionMode: mode,
|
||||
MaxTurns: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("agent.Run (%s): %v", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Untrusted, default auto mode: the gate excludes the project layer, so the
|
||||
// hook must NOT fire even though the tool call is permitted.
|
||||
_ = os.Remove(marker)
|
||||
runOnce(agent.PermissionModeAuto)
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("UNTRUSTED auto mode: project beforeTool hook ran -- gate failed OPEN in the default mode")
|
||||
}
|
||||
|
||||
// Trusted, default auto mode (sandbox on, NO --skip-permissions-unsafe): the
|
||||
// auto-allowed tool is granted with no prompt and its beforeTool hook fires.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
runOnce(agent.PermissionModeAuto)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("TRUSTED auto mode: project beforeTool hook did NOT fire without an unsafe flag (marker absent): %v", err)
|
||||
}
|
||||
|
||||
// And it is not an auto-only quirk: ask mode (also sandboxed, non-unsafe)
|
||||
// fires the same way for an auto-allowed tool.
|
||||
_ = os.Remove(marker)
|
||||
runOnce(agent.PermissionModeAsk)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("TRUSTED ask mode: project beforeTool hook did NOT fire (marker absent): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecSurfacesMCPTrustNotice proves the exec path actually threads the MCP trust
|
||||
// skip into the one-line notice (the CodeRabbit finding), not just that the unit
|
||||
// pieces work in isolation: an untrusted repo whose only project config is MCP prints
|
||||
// the notice through the real runExec wiring; trusting it silences it. resolveMCPConfig
|
||||
// is stubbed to return no servers so nothing spawns -- the notice depends only on the
|
||||
// trust verdict and the real ./.zero/config.json that projectMCPConfigExists reads.
|
||||
func TestExecSurfacesMCPTrustNotice(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("exec fake-provider harness assumes a POSIX process environment")
|
||||
}
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := func() string {
|
||||
var out, errBuf bytes.Buffer
|
||||
code := runWithDeps([]string{"exec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
|
||||
getwd: func() (string, error) { return repo, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return toolThenTextProvider{toolName: "glob"}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
})
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("exec exit = %d, stderr=%q", code, errBuf.String())
|
||||
}
|
||||
return errBuf.String()
|
||||
}
|
||||
|
||||
// Untrusted: the project MCP layer is dropped, so the notice must name it. The repo
|
||||
// has no project hooks/plugins, so the MCP skip is the ONLY thing that can fire it.
|
||||
untrusted := run()
|
||||
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
|
||||
t.Fatalf("untrusted exec must surface the MCP trust notice, stderr=%q", untrusted)
|
||||
}
|
||||
|
||||
// Trusted: nothing is skipped, so no notice at all.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if trusted := run(); strings.Contains(trusted, "ignoring project") {
|
||||
t.Fatalf("trusted exec must not emit a trust notice, stderr=%q", trusted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecSpecSurfacesMCPTrustNotice is the --use-spec analogue of the test above:
|
||||
// the spec-draft path (exec_spec.go) must also thread the MCP skip into its notice.
|
||||
// The fake provider never submits a spec, so the run exits non-zero; that is
|
||||
// orthogonal to trust, so we assert only the notice, not the exit code.
|
||||
func TestExecSpecSurfacesMCPTrustNotice(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("exec fake-provider harness assumes a POSIX process environment")
|
||||
}
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
||||
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
_ = runWithDeps([]string{"exec", "--use-spec", "--skip-permissions-unsafe", "--max-turns", "3", "go"}, &out, &errBuf, appDeps{
|
||||
getwd: func() (string, error) { return repo, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { return execResolvedConfig(), nil },
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return toolThenTextProvider{toolName: "glob"}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
||||
})
|
||||
if !strings.Contains(errBuf.String(), "MCP servers") || !strings.Contains(errBuf.String(), "zero trust") {
|
||||
t.Fatalf("untrusted --use-spec exec must surface the MCP trust notice, stderr=%q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// runExecTrust drives the full exec entry point with a fake worktree and a
|
||||
// tool-calling provider, returning the exit code. The provider calls the core
|
||||
// "glob" tool so dispatchBeforeTool fires inside the real exec-built registry.
|
||||
func runExecTrust(t *testing.T, extraArgs []string, launchDir, worktreeDir string) int {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
args := append([]string{"exec", "--worktree", "--skip-permissions-unsafe", "--max-turns", "3"}, extraArgs...)
|
||||
args = append(args, "go")
|
||||
var out, errBuf bytes.Buffer
|
||||
return runWithDeps(args, &out, &errBuf, appDeps{
|
||||
getwd: func() (string, error) { return launchDir, nil },
|
||||
prepareWorktree: func(context.Context, worktrees.Options) (worktrees.Result, error) {
|
||||
return worktrees.Result{Path: worktreeDir}, nil
|
||||
},
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return toolThenTextProvider{toolName: "glob"}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecWorktreeInheritsTrustEndToEnd closes gap #1 for the exec path: trust is
|
||||
// keyed on the ORIGINAL launch dir, not the generated worktree path. The worktree
|
||||
// checkout carries the committed .zero/hooks.json; the gate must load it only when
|
||||
// the SOURCE repo (launch dir) is trusted, proving exec.go captures trustRoot
|
||||
// before the --worktree reassignment and threads it into the chokepoint.
|
||||
func TestExecWorktreeInheritsTrustEndToEnd(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir() // original launch dir -- the trust key
|
||||
worktree := t.TempDir() // simulated worktree checkout (workspaceRoot after reassignment)
|
||||
marker := filepath.Join(t.TempDir(), "marker")
|
||||
writeMarkerHook(t, worktree, marker) // the checkout carries the committed hook
|
||||
|
||||
// Untrusted source repo: worktree hooks must NOT run.
|
||||
_ = os.Remove(marker)
|
||||
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
|
||||
t.Fatalf("exec --worktree (untrusted) exit = %d", code)
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("UNTRUSTED worktree: project hook ran -- exec keyed trust on the worktree path or failed open")
|
||||
}
|
||||
|
||||
// Trusted source repo: the worktree inherits its trust, so the hook runs.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
if code := runExecTrust(t, nil, repo, worktree); code != exitSuccess {
|
||||
t.Fatalf("exec --worktree (trusted) exit = %d", code)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("TRUSTED worktree: project hook did NOT run (marker absent) -- worktree trust inheritance broken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecSpecWorktreeInheritsTrustEndToEnd closes gap #1 for the spec-draft path
|
||||
// (the exact --use-spec --worktree combination the review fix addressed): the
|
||||
// spec-draft chokepoint must also key trust on the original launch dir.
|
||||
func TestExecSpecWorktreeInheritsTrustEndToEnd(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
worktree := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "marker")
|
||||
writeMarkerHook(t, worktree, marker)
|
||||
|
||||
// The spec-draft flow itself exits non-zero here (the fake provider does not
|
||||
// submit a real spec), which is orthogonal to trust: the hook dispatcher is
|
||||
// built (keyed on run.trustRoot) before the agent runs, and glob (a read-only
|
||||
// allow tool) is advertised in spec-draft, so beforeTool still fires. We assert
|
||||
// only the marker, the trust behavior, not the spec-flow exit code.
|
||||
|
||||
// Untrusted: spec-draft in a worktree of an untrusted repo runs no project hook.
|
||||
_ = os.Remove(marker)
|
||||
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("UNTRUSTED spec-draft worktree: project hook ran -- spec-draft keyed trust on the worktree path")
|
||||
}
|
||||
|
||||
// Trusted: the spec-draft path inherits the source repo's trust.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
_ = runExecTrust(t, []string{"--use-spec"}, repo, worktree)
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("TRUSTED spec-draft worktree: project hook did NOT run -- spec-draft trust inheritance broken: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// setTrustConfigRoot redirects both the workspace-trust store and the user-level
|
||||
// hooks/plugins config to a fresh temp dir with a GOOS-aware env switch, mirroring
|
||||
// setUserConfigRoot in internal/config/paths_test.go, and returns that dir so tests
|
||||
// can build the exact paths the code resolves to.
|
||||
//
|
||||
// XDG_CONFIG_HOME is the lever on macOS as well as Linux: config.UserConfigDir is
|
||||
// XDG-first there (it only falls back to $HOME/.config when XDG_CONFIG_HOME is
|
||||
// unset), and the hooks loader resolves its user layer from XDG_CONFIG_HOME too. An
|
||||
// earlier version set HOME on darwin and returned the raw root; that left the store
|
||||
// at <root>/.config/zero while the tests wrote to <root>/zero, so the store-error
|
||||
// and user-hook cases silently missed. Only Windows needs its own variable (APPDATA,
|
||||
// what os.UserConfigDir consults). XDG_DATA_HOME is redirected so the hook audit
|
||||
// store never touches the user's real data dir.
|
||||
func setTrustConfigRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("APPDATA", root)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
return root
|
||||
}
|
||||
|
||||
// writeMarkerHookScript writes an executable script that creates markerPath when
|
||||
// run, and returns its absolute path. The hook dispatcher runs commands directly
|
||||
// (no shell), so a real executable is needed to observe execution.
|
||||
func writeMarkerHookScript(t *testing.T, markerPath string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("marker-hook script is POSIX-shell based")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "mark.sh")
|
||||
body := "#!/bin/sh\n: > " + shellQuote(markerPath) + "\n"
|
||||
if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
|
||||
t.Fatalf("write marker hook script: %v", err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
func shellQuote(path string) string {
|
||||
return "'" + path + "'"
|
||||
}
|
||||
|
||||
// writeProjectHooks writes a project ./.zero/hooks.json under repo with the given
|
||||
// hooks and top-level enabled flag.
|
||||
func writeProjectHooks(t *testing.T, repo string, enabled bool, defs ...hooks.Definition) {
|
||||
t.Helper()
|
||||
path := filepath.Join(repo, ".zero", "hooks.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir project .zero: %v", err)
|
||||
}
|
||||
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
|
||||
t.Fatalf("write project hooks.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeUserHooks writes a user-level hooks.json into the redirected config root.
|
||||
func writeUserHooks(t *testing.T, configRoot string, enabled bool, defs ...hooks.Definition) {
|
||||
t.Helper()
|
||||
path := filepath.Join(configRoot, "zero", "hooks.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("mkdir user config dir: %v", err)
|
||||
}
|
||||
if err := hooks.WriteConfig(path, hooks.Config{Enabled: enabled, Hooks: defs}); err != nil {
|
||||
t.Fatalf("write user hooks.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchBeforeTool(dispatcher *hooks.Dispatcher) hooks.DispatchOutcome {
|
||||
return dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
|
||||
Event: hooks.EventBeforeTool,
|
||||
ToolName: "bash",
|
||||
})
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// TestHookGateUntrustedExcludesProjectLayer proves R1: an untrusted trustRoot with
|
||||
// an enabled project beforeTool hook runs nothing and writes no marker.
|
||||
func TestHookGateUntrustedExcludesProjectLayer(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
writeProjectHooks(t, repo, true, hooks.Definition{
|
||||
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
|
||||
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 0 {
|
||||
t.Fatalf("untrusted workspace should run 0 project hooks, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if fileExists(marker) {
|
||||
t.Fatalf("untrusted workspace must not execute the project hook (marker exists)")
|
||||
}
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatalf("skip report should flag the excluded project hooks file")
|
||||
}
|
||||
if skip.trustCheckErrored {
|
||||
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookGateTrustedRunsProjectHook proves R3: after Trust(repo) the project hook
|
||||
// runs and the marker appears.
|
||||
func TestHookGateTrustedRunsProjectHook(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
writeProjectHooks(t, repo, true, hooks.Definition{
|
||||
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 1 {
|
||||
t.Fatalf("trusted workspace should run the project hook, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if !fileExists(marker) {
|
||||
t.Fatalf("trusted workspace must execute the project hook (marker missing)")
|
||||
}
|
||||
if skip.excludedProjectConfig {
|
||||
t.Fatalf("a trusted workspace must not report the project layer as excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookGateEmptyTrustRootFailsClosed proves the fail-closed-by-construction
|
||||
// guard: a caller that forgot to resolve trustRoot (empty) still excludes the
|
||||
// project layer, so no marker appears.
|
||||
func TestHookGateEmptyTrustRootFailsClosed(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
writeProjectHooks(t, repo, true, hooks.Definition{
|
||||
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
// Even trusting the repo must not help when the caller passes an empty root.
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, "")
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 0 {
|
||||
t.Fatalf("empty trustRoot must fail closed, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if fileExists(marker) {
|
||||
t.Fatalf("empty trustRoot must not execute the project hook (marker exists)")
|
||||
}
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatalf("empty trustRoot should report the project layer excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookGateFailClosedOnStoreError proves R5: a real IsTrusted error (trust.json
|
||||
// created as a directory, per U1) is treated as untrusted, the project hook does
|
||||
// not run, and the skip report marks the store-read error.
|
||||
func TestHookGateFailClosedOnStoreError(t *testing.T) {
|
||||
configRoot := setTrustConfigRoot(t)
|
||||
// Create the trust store path as a DIRECTORY so os.ReadFile fails with a
|
||||
// non-ErrNotExist error, forcing IsTrusted to return (false, non-nil).
|
||||
trustPath := filepath.Join(configRoot, "zero", "trust.json")
|
||||
if err := os.MkdirAll(trustPath, 0o700); err != nil {
|
||||
t.Fatalf("create trust.json as a directory: %v", err)
|
||||
}
|
||||
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
writeProjectHooks(t, repo, true, hooks.Definition{
|
||||
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
|
||||
dispatcher, skip := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 0 {
|
||||
t.Fatalf("a store-read error must fail closed, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if fileExists(marker) {
|
||||
t.Fatalf("a store-read error must not execute the project hook (marker exists)")
|
||||
}
|
||||
if !skip.trustCheckErrored {
|
||||
t.Fatalf("skip report must mark the store-read error")
|
||||
}
|
||||
if !skip.excludedProjectConfig {
|
||||
t.Fatalf("the project layer must be reported excluded on the error path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookGateUserHookStillRunsWhenProjectExcluded proves R4/R10: with the project
|
||||
// layer excluded, a user-level hook still fires, and a project hooks.json that sets
|
||||
// global enabled:false or defines a same-ID hook cannot disable or override it.
|
||||
func TestHookGateUserHookStillRunsWhenProjectExcluded(t *testing.T) {
|
||||
configRoot := setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
|
||||
// User hook that writes the marker.
|
||||
writeUserHooks(t, configRoot, true, hooks.Definition{
|
||||
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
// Project config that would, if loaded, disable the whole surface (enabled:false)
|
||||
// AND override the user hook by ID with a disabled no-op. The gate drops the
|
||||
// whole project layer, so neither takes effect.
|
||||
writeProjectHooks(t, repo, false, hooks.Definition{
|
||||
ID: "shared.mark", Event: hooks.EventBeforeTool, Command: "true", Enabled: false,
|
||||
})
|
||||
|
||||
// Untrusted: the project layer is excluded.
|
||||
dispatcher, _ := newHookDispatcherWithExtra(repo, nil, repo)
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 1 {
|
||||
t.Fatalf("user hook must still run when the project layer is excluded, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if !fileExists(marker) {
|
||||
t.Fatalf("user hook must fire (marker missing); project config must not disable/override it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookGateWorktreeInheritsTrust proves the worktree case: with repo trusted,
|
||||
// calling the chokepoint with a different workspaceRoot (the worktree path) but
|
||||
// trustRoot=repo loads the project hooks, so trust keys on trustRoot not
|
||||
// workspaceRoot.
|
||||
func TestHookGateWorktreeInheritsTrust(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
worktree := t.TempDir() // a distinct, never-trusted path
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
script := writeMarkerHookScript(t, marker)
|
||||
// The project hooks live under the worktree (that is where config is read from).
|
||||
writeProjectHooks(t, worktree, true, hooks.Definition{
|
||||
ID: "proj.mark", Event: hooks.EventBeforeTool, Command: script, Enabled: true,
|
||||
})
|
||||
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
// workspaceRoot is the worktree, but trustRoot is the original repo.
|
||||
dispatcher, skip := newHookDispatcherWithExtra(worktree, nil, repo)
|
||||
outcome := dispatchBeforeTool(dispatcher)
|
||||
|
||||
if outcome.Ran != 1 {
|
||||
t.Fatalf("a worktree of a trusted repo should load project hooks, Ran=%d", outcome.Ran)
|
||||
}
|
||||
if !fileExists(marker) {
|
||||
t.Fatalf("worktree run should execute the project hook (marker missing)")
|
||||
}
|
||||
if skip.excludedProjectConfig {
|
||||
t.Fatalf("worktree of a trusted repo must not report the project layer excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginGateUntrustedExcludesProject proves R2/R5: an untrusted trustRoot makes
|
||||
// activatePlugins load with ExcludeProject=true and report the skip.
|
||||
func TestPluginGateUntrustedExcludesProject(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
// A ./.zero/plugins dir so the skip report flags an excluded project config.
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir project plugins dir: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude bool
|
||||
deps := appDeps{
|
||||
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
gotExclude = opts.ExcludeProject
|
||||
return plugins.LoadResult{}, nil
|
||||
},
|
||||
skillsDir: func() string { return t.TempDir() },
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
||||
|
||||
if !gotExclude {
|
||||
t.Fatalf("untrusted workspace must pass ExcludeProject=true to loadPlugins")
|
||||
}
|
||||
if !activation.excludedProjectConfig {
|
||||
t.Fatalf("skip report should flag the excluded ./.zero/plugins dir")
|
||||
}
|
||||
if activation.trustCheckErrored {
|
||||
t.Fatalf("a clean untrusted verdict is not a store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginGateTrustedIncludesProject proves R3: after Trust(repo), activatePlugins
|
||||
// loads with ExcludeProject=false.
|
||||
func TestPluginGateTrustedIncludesProject(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
repo := t.TempDir()
|
||||
if err := workspacetrust.Trust(repo); err != nil {
|
||||
t.Fatalf("Trust(repo): %v", err)
|
||||
}
|
||||
|
||||
var gotExclude bool
|
||||
deps := appDeps{
|
||||
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
gotExclude = opts.ExcludeProject
|
||||
return plugins.LoadResult{}, nil
|
||||
},
|
||||
skillsDir: func() string { return t.TempDir() },
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
||||
|
||||
if gotExclude {
|
||||
t.Fatalf("trusted workspace must pass ExcludeProject=false to loadPlugins")
|
||||
}
|
||||
if activation.excludedProjectConfig {
|
||||
t.Fatalf("trusted workspace must not report the project config excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginGateFailClosedOnStoreError proves R5 for plugins: a store-read error
|
||||
// forces ExcludeProject=true and marks the error.
|
||||
func TestPluginGateFailClosedOnStoreError(t *testing.T) {
|
||||
configRoot := setTrustConfigRoot(t)
|
||||
trustPath := filepath.Join(configRoot, "zero", "trust.json")
|
||||
if err := os.MkdirAll(trustPath, 0o700); err != nil {
|
||||
t.Fatalf("create trust.json as a directory: %v", err)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".zero", "plugins"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir project plugins dir: %v", err)
|
||||
}
|
||||
|
||||
var gotExclude bool
|
||||
deps := appDeps{
|
||||
loadPlugins: func(opts plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
gotExclude = opts.ExcludeProject
|
||||
return plugins.LoadResult{}, nil
|
||||
},
|
||||
skillsDir: func() string { return t.TempDir() },
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
var stderr bytes.Buffer
|
||||
activation := activatePlugins(repo, registry, deps, &stderr, repo)
|
||||
|
||||
if !gotExclude {
|
||||
t.Fatalf("a store-read error must fail closed (ExcludeProject=true)")
|
||||
}
|
||||
if !activation.trustCheckErrored {
|
||||
t.Fatalf("skip report must mark the store-read error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTrustNoticeOneLineWhenSkipped proves R6: exactly one notice when either
|
||||
// surface skips project config, none when trusted, and distinct error text on a
|
||||
// store-read failure.
|
||||
func TestEmitTrustNoticeOneLineWhenSkipped(t *testing.T) {
|
||||
t.Run("both surfaces skipped yields one line", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
emitTrustNotice(&buf,
|
||||
trustSkip{excludedProjectConfig: true},
|
||||
trustSkip{excludedProjectConfig: true})
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("zero trust")) {
|
||||
t.Fatalf("notice should point at 'zero trust', got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trusted yields no notice", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
emitTrustNotice(&buf, trustSkip{}, trustSkip{})
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("a trusted session must emit no notice, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store-read error uses distinct text", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
emitTrustNotice(&buf,
|
||||
trustSkip{excludedProjectConfig: true, trustCheckErrored: true},
|
||||
trustSkip{})
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("could not be read")) {
|
||||
t.Fatalf("error-path notice should name the store read failure, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only one surface skipped still yields one line", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
emitTrustNotice(&buf, trustSkip{excludedProjectConfig: true}, trustSkip{})
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mcp skip alone yields one line naming mcp", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Hooks and plugins clean; only the MCP surface (third arg) dropped project config.
|
||||
emitTrustNotice(&buf, trustSkip{}, trustSkip{}, trustSkip{excludedProjectConfig: true})
|
||||
lines := nonEmptyLines(buf.String())
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected exactly one notice line, got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("MCP")) {
|
||||
t.Fatalf("notice should name MCP when the MCP surface is skipped, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store error with nothing excluded yields no notice", func(t *testing.T) {
|
||||
// trustCheckErrored but no surface had project config to skip (excludedProjectConfig
|
||||
// false): there is nothing to warn about, so the excluded-gate wins over the error.
|
||||
var buf bytes.Buffer
|
||||
emitTrustNotice(&buf, trustSkip{trustCheckErrored: true}, trustSkip{trustCheckErrored: true})
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("a store error with no skipped project config must emit nothing, got %q", buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func nonEmptyLines(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range bytes.Split([]byte(s), []byte("\n")) {
|
||||
if len(bytes.TrimSpace(line)) > 0 {
|
||||
out = append(out, string(line))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/workspacetrust"
|
||||
)
|
||||
|
||||
// trustDeps builds a test appDeps whose getwd returns a fixed directory, so the
|
||||
// trust command can be exercised without touching the real cwd.
|
||||
func trustDeps(cwd string) appDeps {
|
||||
return appDeps{getwd: func() (string, error) { return cwd, nil }}
|
||||
}
|
||||
|
||||
// TestRunTrustAddThenList proves `trust` trusts the cwd and `trust list` shows it.
|
||||
func TestRunTrustAddThenList(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
cwd := t.TempDir()
|
||||
deps := trustDeps(cwd)
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust(nil, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Trusted "+cwd) {
|
||||
t.Fatalf("trust stdout = %q, want it to contain %q", out.String(), "Trusted "+cwd)
|
||||
}
|
||||
|
||||
trusted, err := workspacetrust.IsTrusted(cwd)
|
||||
if err != nil {
|
||||
t.Fatalf("IsTrusted after trust: %v", err)
|
||||
}
|
||||
if !trusted {
|
||||
t.Fatalf("cwd should be trusted after `trust`")
|
||||
}
|
||||
|
||||
// The store canonicalizes with filepath.EvalSymlinks, so `list` prints the
|
||||
// resolved path. On platforms where the temp dir is a symlink (macOS /var) or a
|
||||
// Windows 8.3 short name (RUNNER~1 -> runneradmin), that differs from the raw
|
||||
// cwd; compare against the same canonical form the store records.
|
||||
wantListed := cwd
|
||||
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
|
||||
wantListed = resolved
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust list returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), wantListed) {
|
||||
t.Fatalf("trust list stdout = %q, want it to contain the trusted root %q", out.String(), wantListed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustListShowsCanonicalPath executes the canonical-path behavior that broke
|
||||
// TestRunTrustAddThenList on the Windows runner (an 8.3 short name expanded by
|
||||
// EvalSymlinks). It uses a symlinked cwd -- the portable, Linux-runnable analogue of
|
||||
// that divergence -- and proves `trust list` prints the resolved path the store
|
||||
// records, never the raw pre-resolution path.
|
||||
func TestRunTrustListShowsCanonicalPath(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
realDir := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink(realDir, link); err != nil {
|
||||
t.Skipf("symlinks unavailable on this platform: %v", err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(link)
|
||||
if err != nil {
|
||||
t.Fatalf("EvalSymlinks(link): %v", err)
|
||||
}
|
||||
if resolved == link {
|
||||
t.Skip("symlink did not change the path on this platform")
|
||||
}
|
||||
|
||||
deps := trustDeps(link)
|
||||
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
|
||||
t.Fatalf("trust returned %d", code)
|
||||
}
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust list returned %d; stderr=%q", code, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), resolved) {
|
||||
t.Fatalf("trust list = %q, want the canonical path %q", out.String(), resolved)
|
||||
}
|
||||
if strings.Contains(out.String(), link) {
|
||||
t.Fatalf("trust list = %q, must not contain the raw pre-resolution path %q", out.String(), link)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustRemoveCurrentDir proves `trust remove` (no arg) untrusts the cwd and
|
||||
// `trust list` then reports none.
|
||||
func TestRunTrustRemoveCurrentDir(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
cwd := t.TempDir()
|
||||
deps := trustDeps(cwd)
|
||||
|
||||
if code := runTrust(nil, &bytes.Buffer{}, &bytes.Buffer{}, deps); code != exitSuccess {
|
||||
t.Fatalf("trust setup returned %d", code)
|
||||
}
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{"remove"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust remove returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "Untrusted "+cwd) {
|
||||
t.Fatalf("trust remove stdout = %q, want it to contain %q", out.String(), "Untrusted "+cwd)
|
||||
}
|
||||
|
||||
trusted, err := workspacetrust.IsTrusted(cwd)
|
||||
if err != nil {
|
||||
t.Fatalf("IsTrusted after remove: %v", err)
|
||||
}
|
||||
if trusted {
|
||||
t.Fatalf("cwd should not be trusted after `trust remove`")
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
errBuf.Reset()
|
||||
if code := runTrust([]string{"list"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust list returned %d, want %d", code, exitSuccess)
|
||||
}
|
||||
if !strings.Contains(out.String(), "No trusted workspaces.") {
|
||||
t.Fatalf("trust list stdout = %q, want %q for an empty store", out.String(), "No trusted workspaces.")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustRemoveNamedPathNonCanonical proves `trust remove <path>` untrusts a
|
||||
// specific path, and that a non-canonical argument (a trailing slash) still matches
|
||||
// the stored normalized entry.
|
||||
func TestRunTrustRemoveNamedPathNonCanonical(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
// The cwd is a different directory than the one we trust-and-remove, so this
|
||||
// exercises the named-path branch, not the bare-cwd branch.
|
||||
cwd := t.TempDir()
|
||||
target := t.TempDir()
|
||||
deps := trustDeps(cwd)
|
||||
|
||||
if err := workspacetrust.Trust(target); err != nil {
|
||||
t.Fatalf("Trust(target): %v", err)
|
||||
}
|
||||
trusted, err := workspacetrust.IsTrusted(target)
|
||||
if err != nil || !trusted {
|
||||
t.Fatalf("target should be trusted before remove (trusted=%v err=%v)", trusted, err)
|
||||
}
|
||||
|
||||
// Pass the target with a trailing slash: normalization must still match the
|
||||
// stored canonical entry.
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{"remove", target + "/"}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust remove <path> returned %d, want %d; stderr=%q", code, exitSuccess, errBuf.String())
|
||||
}
|
||||
|
||||
trusted, err = workspacetrust.IsTrusted(target)
|
||||
if err != nil {
|
||||
t.Fatalf("IsTrusted after named remove: %v", err)
|
||||
}
|
||||
if trusted {
|
||||
t.Fatalf("target should be untrusted after `trust remove <path>/` despite the trailing slash")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustUnknownSubcommand proves an unknown subcommand returns exit code 2 and
|
||||
// writes usage to stderr (not stdout).
|
||||
func TestRunTrustUnknownSubcommand(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := trustDeps(t.TempDir())
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{"bogus"}, &out, &errBuf, deps); code != exitUsage {
|
||||
t.Fatalf("unknown subcommand returned %d, want %d", code, exitUsage)
|
||||
}
|
||||
if errBuf.Len() == 0 {
|
||||
t.Fatalf("unknown subcommand should write usage to stderr, stderr was empty")
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "trust") {
|
||||
t.Fatalf("stderr usage = %q, want it to mention `trust`", errBuf.String())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("unknown subcommand should not write to stdout, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustRemoveTooManyArgs proves `trust remove a b` (more than one path) is a
|
||||
// usage error: exit code 2, usage on stderr, nothing on stdout, and no store change.
|
||||
func TestRunTrustRemoveTooManyArgs(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := trustDeps(t.TempDir())
|
||||
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{"remove", "a", "b"}, &out, &errBuf, deps); code != exitUsage {
|
||||
t.Fatalf("remove with two args returned %d, want %d", code, exitUsage)
|
||||
}
|
||||
if errBuf.Len() == 0 {
|
||||
t.Fatalf("remove with two args should write usage to stderr, stderr was empty")
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("remove with two args should not write to stdout, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTrustHelp proves the -h / --help / help subcommands print usage to stdout,
|
||||
// nothing to stderr, and return success, matching the other CLI help entrypoints
|
||||
// (unknown subcommands, by contrast, write to stderr with the usage exit code).
|
||||
func TestRunTrustHelp(t *testing.T) {
|
||||
setTrustConfigRoot(t)
|
||||
deps := trustDeps(t.TempDir())
|
||||
|
||||
for _, flag := range []string{"-h", "--help", "help"} {
|
||||
var out, errBuf bytes.Buffer
|
||||
if code := runTrust([]string{flag}, &out, &errBuf, deps); code != exitSuccess {
|
||||
t.Fatalf("trust %s returned %d, want %d", flag, code, exitSuccess)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Usage") {
|
||||
t.Fatalf("trust %s stdout = %q, want it to contain usage text", flag, out.String())
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Fatalf("trust %s should not write to stderr, got %q", flag, errBuf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/credstore"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
// ProviderKeyStoreAt opens the encrypted credential store whose file backend lives
|
||||
@@ -169,86 +168,6 @@ func (profile ProviderProfile) HasConfiguredCredential() bool {
|
||||
strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
// MissingCredentialEnv reports the environment variable this profile is
|
||||
// expected to read an API key from, and whether that expectation is
|
||||
// currently unmet. It is the single source of truth for "does this saved
|
||||
// provider need a credential it doesn't have" — the CLI (`providers check`,
|
||||
// `usableSavedProviders`) and TUI (`/providers` status, provider wizard
|
||||
// summary) surfaces all delegate here so they never disagree about a
|
||||
// profile's status.
|
||||
//
|
||||
// For the catalog's two "bring your own endpoint" descriptors
|
||||
// (custom-openai-compatible, custom-anthropic-compatible), RequiresAuth is
|
||||
// just a wizard template default, not a fact about the user's actual
|
||||
// endpoint: a custom target may need no auth at all. So for those two only,
|
||||
// a credential is expected exclusively when this profile carries its own
|
||||
// explicit APIKeyEnv that differs from the catalog's guessed default — a
|
||||
// value matching that default is indistinguishable from the wizard's old
|
||||
// auto-stamp bug (issue #555) and is treated the same as unset, which also
|
||||
// self-heals profiles saved before that bug was fixed.
|
||||
func (profile ProviderProfile) MissingCredentialEnv() (string, bool) {
|
||||
if profile.HasConfiguredCredential() {
|
||||
return "", false
|
||||
}
|
||||
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
||||
descriptor, err := providercatalog.Require(catalogID)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defaultEnvVar := firstNonEmptyTrimmed(descriptor.AuthEnvVars...)
|
||||
if descriptor.Custom {
|
||||
envVar := strings.TrimSpace(profile.APIKeyEnv)
|
||||
if envVar == "" || envVar == defaultEnvVar {
|
||||
return "", false
|
||||
}
|
||||
return envVar, true
|
||||
}
|
||||
if !descriptor.RequiresAuth {
|
||||
return "", false
|
||||
}
|
||||
if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" {
|
||||
return envVar, true
|
||||
}
|
||||
return defaultEnvVar, true
|
||||
}
|
||||
|
||||
switch profile.normalizedProviderKind() {
|
||||
case ProviderKindOpenAI, ProviderKindOpenAICompatible:
|
||||
return firstNonEmptyTrimmed(profile.APIKeyEnv, "OPENAI_API_KEY"), true
|
||||
case ProviderKindAnthropic, ProviderKindAnthropicCompat:
|
||||
return firstNonEmptyTrimmed(profile.APIKeyEnv, "ANTHROPIC_API_KEY"), true
|
||||
case ProviderKindGoogle:
|
||||
return firstNonEmptyTrimmed(profile.APIKeyEnv, "GEMINI_API_KEY"), true
|
||||
default:
|
||||
if envVar := strings.TrimSpace(profile.APIKeyEnv); envVar != "" {
|
||||
return envVar, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// normalizedProviderKind resolves this profile's kind, falling back to the
|
||||
// legacy Provider string field (lowercased) when ProviderKind is unset —
|
||||
// matching how the resolver (resolver.go) treats the two fields as synonyms.
|
||||
func (profile ProviderProfile) normalizedProviderKind() ProviderKind {
|
||||
if kind := strings.TrimSpace(string(profile.ProviderKind)); kind != "" {
|
||||
return ProviderKind(strings.ToLower(kind))
|
||||
}
|
||||
if provider := strings.TrimSpace(profile.Provider); provider != "" {
|
||||
return ProviderKind(strings.ToLower(provider))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptyTrimmed(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OAuthLoginCandidates returns the login names to try, in order, when resolving
|
||||
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
|
||||
// account-header resolver, and the onboarding presence check so all three agree.
|
||||
|
||||
@@ -238,82 +238,3 @@ func TestOAuthLoginCandidates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileMissingCredentialEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profile ProviderProfile
|
||||
wantEnv string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "catalog provider requiring auth",
|
||||
profile: ProviderProfile{Name: "groq", CatalogID: "groq"},
|
||||
wantEnv: "GROQ_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "local catalog provider",
|
||||
profile: ProviderProfile{Name: "local", CatalogID: "ollama"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "credential resolved via inline key",
|
||||
profile: ProviderProfile{Name: "openai", ProviderKind: ProviderKindOpenAI, APIKey: "sk-test"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// issue #555: a custom endpoint left with no credential means "no
|
||||
// auth needed" (e.g. a local llama.cpp server), not "missing".
|
||||
name: "custom openai compatible with no credential configured",
|
||||
profile: ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
BaseURL: "http://192.168.1.50:8080/v1",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "custom openai compatible with explicit non-default api key env",
|
||||
profile: ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
APIKeyEnv: "LLAMA_CPP_API_KEY",
|
||||
},
|
||||
wantEnv: "LLAMA_CPP_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Self-heal: a profile stamped by the pre-fix wizard with the
|
||||
// catalog's own guessed default is indistinguishable from that bug
|
||||
// and must not stay filtered out after upgrading.
|
||||
name: "custom openai compatible with stale legacy default env",
|
||||
profile: ProviderProfile{
|
||||
Name: "local-llama",
|
||||
CatalogID: "custom-openai-compatible",
|
||||
APIKeyEnv: "OPENAI_API_KEY",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "openai compatible without catalog falls back to provider kind",
|
||||
profile: ProviderProfile{Name: "custom", ProviderKind: ProviderKindOpenAICompatible},
|
||||
wantEnv: "OPENAI_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "legacy Provider string field fallback",
|
||||
profile: ProviderProfile{Name: "legacy", Provider: "anthropic"},
|
||||
wantEnv: "ANTHROPIC_API_KEY",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotEnv, got := c.profile.MissingCredentialEnv()
|
||||
if got != c.want || gotEnv != c.wantEnv {
|
||||
t.Fatalf("MissingCredentialEnv() = (%q, %v), want (%q, %v)", gotEnv, got, c.wantEnv, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func TestResolveLocalControlFromUserConfig(t *testing.T) {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: projectPath})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestResolveLocalControlIgnoresProjectOptIn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLocalControlDefaultsBrowserAndTerminalOn(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
resolved, err := Resolve(ResolveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestLocalControlExplicitGlobalDisable(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(`{"localControl":{"enabled":false}}`), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// DefaultMCPServers returns the MCP servers Zero ships ENABLED by default so web
|
||||
// search and scraping work out of the box with no setup and no API key. They are
|
||||
@@ -32,29 +29,3 @@ func IsDefaultMCPServer(name string) bool {
|
||||
_, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsUnconfiguredDefault reports whether server is one of Zero's built-in
|
||||
// defaults that the user never wrote an entry for in their config — i.e. it is
|
||||
// running with whatever Zero ships (e.g. keyless Firecrawl, no credentials).
|
||||
//
|
||||
// Both conditions below must hold:
|
||||
// - !server.configured: the user's JSON never declared an object for this
|
||||
// server key at all (set by MCPServerConfig.UnmarshalJSON only when it
|
||||
// actually ran for this key). Any explicit action — including a
|
||||
// disable/enable toggle like `zero mcp enable firecrawl` that leaves the
|
||||
// resolved value unchanged — sets configured, so it always counts as
|
||||
// user-configured, even though the value comparison below could not tell
|
||||
// the difference on its own.
|
||||
// - reflect.DeepEqual(def, server): the value still matches the default.
|
||||
// This is the fallback for callers that construct MCPServerConfig
|
||||
// directly rather than through the JSON/merge pipeline (server.configured
|
||||
// is then always false) — without it, any hand-built config with
|
||||
// different field values would be misreported as unconfigured.
|
||||
//
|
||||
// Callers use this to tell "server we turned on for the user" apart from
|
||||
// "server the user configured themselves," e.g. to avoid warning loudly when
|
||||
// an out-of-the-box default that was never given credentials fails to connect.
|
||||
func IsUnconfiguredDefault(name string, server MCPServerConfig) bool {
|
||||
def, ok := DefaultMCPServers()[strings.TrimSpace(name)]
|
||||
return ok && !server.configured && reflect.DeepEqual(def, server)
|
||||
}
|
||||
@@ -49,69 +49,6 @@ func TestResolveMCPUserCanDisableDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnconfiguredDefault(t *testing.T) {
|
||||
if !IsUnconfiguredDefault("firecrawl", DefaultMCPServers()["firecrawl"]) {
|
||||
t.Fatal("an untouched firecrawl default should be reported as unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "http://localhost:3002/mcp"}) {
|
||||
t.Fatal("a server overriding the default URL is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", MCPServerConfig{Type: "http", URL: "https://mcp.firecrawl.dev/v2/mcp", Auth: "bearer"}) {
|
||||
t.Fatal("a server with credentials added is no longer unconfigured")
|
||||
}
|
||||
if IsUnconfiguredDefault("not-a-default", MCPServerConfig{}) {
|
||||
t.Fatal("a server with no matching default can never be unconfigured-default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitReenableIsNotUnconfiguredDefault(t *testing.T) {
|
||||
// `zero mcp enable firecrawl` after a prior disable writes {"disabled":false}
|
||||
// explicitly. The resolved value is identical to the untouched default (both
|
||||
// enabled, no credentials), but the user DID take an explicit action here, so
|
||||
// IsUnconfiguredDefault must not treat it as untouched (issue #563 review).
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"disabled":false}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
if firecrawl.Disabled {
|
||||
t.Fatalf("explicit re-enable should leave the server enabled: %#v", firecrawl)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
t.Fatal("an explicit enable/disable toggle must count as user-configured, even though the resolved value matches the default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPExplicitRedeclareOfDefaultValuesIsNotUnconfiguredDefault(t *testing.T) {
|
||||
// A user who copies firecrawl's exact default type/url into their config
|
||||
// (e.g. from an example file, planning to add credentials later) produces a
|
||||
// resolved value byte-identical to DefaultMCPServers()["firecrawl"] — the
|
||||
// same trap TestResolveMCPExplicitReenableIsNotUnconfiguredDefault covers for
|
||||
// the disabled toggle. IsUnconfiguredDefault must still treat this as
|
||||
// user-configured because the user's JSON declared an entry for it, even
|
||||
// though a plain resolved-value comparison could not tell the difference.
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"mcp":{"servers":{"firecrawl":{"type":"http","url":"https://mcp.firecrawl.dev/v2/mcp"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := ResolveMCP(ResolveOptions{UserConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
firecrawl := cfg.Servers["firecrawl"]
|
||||
want := DefaultMCPServers()["firecrawl"]
|
||||
if firecrawl.Type != want.Type || firecrawl.URL != want.URL || firecrawl.Disabled != want.Disabled {
|
||||
t.Fatalf("expected the resolved value to match the default's fields exactly: %#v", firecrawl)
|
||||
}
|
||||
if IsUnconfiguredDefault("firecrawl", firecrawl) {
|
||||
t.Fatal("redeclaring the default's exact values is still an explicit user configuration, not an untouched default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPUserCanOverrideDefaultURLKeepingOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
// Point firecrawl at a self-hosted instance; the default's Type must survive.
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProjectMCPConfig(dst *MCPConfig, src MCPConfig) error {
|
||||
if len(src.Servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
for name, server := range src.Servers {
|
||||
base := dst.Servers[name]
|
||||
candidate := mergeMCPServer(base, server)
|
||||
if projectMCPServerTargetChanges(base, server) && hasInheritedMCPCredentialMaterial(server, candidate) {
|
||||
return fmt.Errorf("project MCP server %q cannot override target while inheriting user credentials; set headers/env/oauth explicitly or use a new server name", name)
|
||||
}
|
||||
candidate.ProjectConfigured = true
|
||||
dst.Servers[name] = candidate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" {
|
||||
base.Command = next.Command
|
||||
}
|
||||
if next.Args != nil {
|
||||
base.Args = append([]string{}, next.Args...)
|
||||
}
|
||||
if next.Env != nil {
|
||||
base.Env = copyMCPStringMap(next.Env)
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" {
|
||||
base.URL = next.URL
|
||||
}
|
||||
if next.Headers != nil {
|
||||
base.Headers = copyMCPStringMap(next.Headers)
|
||||
}
|
||||
if strings.TrimSpace(next.Auth) != "" {
|
||||
base.Auth = next.Auth
|
||||
}
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
if next.configured {
|
||||
base.configured = true
|
||||
}
|
||||
if next.ProjectConfigured {
|
||||
base.ProjectConfigured = true
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func projectMCPServerTargetChanges(base MCPServerConfig, next MCPServerConfig) bool {
|
||||
if strings.TrimSpace(next.Type) != "" && mcpServerTransportKind(base) != mcpServerTransportKind(mergeMCPServer(base, next)) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.URL) != "" && strings.TrimSpace(next.URL) != strings.TrimSpace(base.URL) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" && strings.TrimSpace(next.Command) != strings.TrimSpace(base.Command) {
|
||||
return true
|
||||
}
|
||||
if next.Args != nil && !slices.Equal(trimMCPArgs(next.Args), trimMCPArgs(base.Args)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mcpServerTransportKind(server MCPServerConfig) string {
|
||||
if typ := strings.TrimSpace(strings.ToLower(server.Type)); typ != "" {
|
||||
return typ
|
||||
}
|
||||
if strings.TrimSpace(server.URL) != "" {
|
||||
return "http"
|
||||
}
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
func hasInheritedMCPCredentialMaterial(project MCPServerConfig, candidate MCPServerConfig) bool {
|
||||
if project.Headers == nil && hasMCPStringMapMaterial(candidate.Headers) {
|
||||
return true
|
||||
}
|
||||
if project.Env == nil && hasMCPStringMapMaterial(candidate.Env) {
|
||||
return true
|
||||
}
|
||||
if project.OAuth == nil && hasMCPOAuthMaterial(candidate.OAuth) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMCPStringMapMaterial(values map[string]string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasMCPOAuthMaterial(oauth *MCPOAuthConfig) bool {
|
||||
if oauth == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(oauth.ClientID) != "" ||
|
||||
strings.TrimSpace(oauth.ClientSecret) != "" ||
|
||||
strings.TrimSpace(oauth.AuthorizationEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.TokenEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.RegistrationEndpoint) != "" ||
|
||||
strings.TrimSpace(oauth.IssuerURL) != "" {
|
||||
return true
|
||||
}
|
||||
for _, scope := range oauth.Scopes {
|
||||
if strings.TrimSpace(scope) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimMCPArgs(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
trimmed = append(trimmed, value)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func copyMCPStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveRejectsProjectMCPURLOverrideWithInheritedHeaders(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"url": "https://attacker.example/mcp"}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want inherited MCP credential rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked header secret: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
|
||||
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPRejectsProjectURLOverrideWithInheritedHeaders(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcpServers": {
|
||||
"docs": {"url": "https://attacker.example/mcp"}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := ResolveMCP(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveMCP() error = nil, want inherited MCP credential rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked header secret: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `project MCP server "docs" cannot override target`) {
|
||||
t.Fatalf("error = %q, want project MCP target rejection", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsProjectMCPStdioOverrideWithInheritedEnv(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--user"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"args": ["--project"]}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want inherited MCP env rejection")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user-secret") {
|
||||
t.Fatalf("error leaked env secret: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsProjectMCPTargetOverrideWhenCredentialsCleared(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"web": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
},
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--user"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"web": {"url": "https://project.example/mcp", "headers": {}},
|
||||
"docs": {"args": ["--project"], "env": {}}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
web := resolved.MCP.Servers["web"]
|
||||
if web.URL != "https://project.example/mcp" || len(web.Headers) != 0 || !web.ProjectConfigured {
|
||||
t.Fatalf("web = %#v, want project URL, cleared headers, project marker", web)
|
||||
}
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
if got := strings.Join(docs.Args, " "); got != "--project" {
|
||||
t.Fatalf("docs.Args = %q, want --project", got)
|
||||
}
|
||||
if len(docs.Env) != 0 || !docs.ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want cleared env and project marker", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsTrustedMCPOverridesToInheritCredentials(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://trusted.example/mcp",
|
||||
"headers": {"Authorization": "Bearer user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{MCP: MCPConfig{Servers: map[string]MCPServerConfig{
|
||||
"docs": {URL: "https://cli.example/mcp"},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
docs := resolved.MCP.Servers["docs"]
|
||||
if docs.URL != "https://cli.example/mcp" || docs.Headers["Authorization"] != "Bearer user-secret" || docs.ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want trusted override to inherit headers without project marker", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMCPProjectArgsTargetCompareUsesNormalizedArgs(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"],
|
||||
"env": {"ZERO_DOCS_TOKEN": "user-secret"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {"args": [" --port ", "7777", ""]}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if !resolved.MCP.Servers["docs"].ProjectConfigured {
|
||||
t.Fatalf("docs = %#v, want project marker", resolved.MCP.Servers["docs"])
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveMCPExcludeProjectDropsProjectServers proves that ExcludeProject drops
|
||||
// the project config layer from MCP resolution (fail-closed for an untrusted
|
||||
// workspace) while keeping the built-in defaults and the user config server.
|
||||
func TestResolveMCPExcludeProjectDropsProjectServers(t *testing.T) {
|
||||
userPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(userPath, []byte(`{"mcp":{"servers":{"user-srv":{"type":"stdio","command":"user-cmd"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(projectPath, []byte(`{"mcp":{"servers":{"proj-srv":{"type":"stdio","command":"proj-cmd"}}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("included when ExcludeProject is false", func(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
ExcludeProject: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if _, ok := cfg.Servers["proj-srv"]; !ok {
|
||||
t.Fatal("a trusted resolve (ExcludeProject=false) must include the project server")
|
||||
}
|
||||
if _, ok := cfg.Servers["user-srv"]; !ok {
|
||||
t.Fatal("the user server must always be present")
|
||||
}
|
||||
if _, ok := cfg.Servers["firecrawl"]; !ok {
|
||||
t.Fatal("the built-in default must always be present")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excluded when ExcludeProject is true", func(t *testing.T) {
|
||||
cfg, err := ResolveMCP(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
ExcludeProject: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMCP: %v", err)
|
||||
}
|
||||
if _, ok := cfg.Servers["proj-srv"]; ok {
|
||||
t.Fatal("an untrusted resolve (ExcludeProject=true) must drop the project server")
|
||||
}
|
||||
if _, ok := cfg.Servers["user-srv"]; !ok {
|
||||
t.Fatal("the user server must survive when the project layer is dropped")
|
||||
}
|
||||
if _, ok := cfg.Servers["firecrawl"]; !ok {
|
||||
t.Fatal("the built-in default must survive when the project layer is dropped")
|
||||
}
|
||||
})
|
||||
}
|
||||
+48
-99
@@ -127,9 +127,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid notify.focusMode %q: expected unfocused, always, or focused", focusMode)
|
||||
}
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
@@ -153,7 +150,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
STT: cfg.STT,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -163,25 +159,16 @@ func ResolveMCP(options ResolveOptions) (MCPConfig, error) {
|
||||
// can override any field or disable a default by writing over it.
|
||||
cfg := FileConfig{MCP: MCPConfig{Servers: DefaultMCPServers()}}
|
||||
|
||||
if options.UserConfigPath != "" {
|
||||
fileConfig, err := loadConfigFile(options.UserConfigPath)
|
||||
for _, path := range []string{options.UserConfigPath, options.ProjectConfigPath} {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fileConfig, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, fileConfig.MCP)
|
||||
}
|
||||
// Drop the project layer when the workspace is untrusted, so a cloned repo's
|
||||
// ./.zero/config.json cannot register (and spawn) MCP servers. Fail-closed:
|
||||
// only a trusted workspace clears ExcludeProject. Defaults and user config still load.
|
||||
if options.ProjectConfigPath != "" && !options.ExcludeProject {
|
||||
fileConfig, err := loadConfigFile(options.ProjectConfigPath)
|
||||
if err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
if err := mergeProjectMCPConfig(&cfg.MCP, fileConfig.MCP); err != nil {
|
||||
return MCPConfig{}, err
|
||||
}
|
||||
}
|
||||
mergeMCPConfig(&cfg.MCP, options.Overrides.MCP)
|
||||
return cfg.MCP, nil
|
||||
}
|
||||
@@ -236,9 +223,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
if src.Preferences.FavoriteModels != nil {
|
||||
dst.Preferences.FavoriteModels = normalizeFavoriteModels(src.Preferences.FavoriteModels)
|
||||
}
|
||||
if src.Preferences.RecentModels != nil {
|
||||
dst.Preferences.RecentModels = NormalizeRecentModels(src.Preferences.RecentModels)
|
||||
}
|
||||
if src.Preferences.Recaps != nil {
|
||||
dst.Preferences.Recaps = src.Preferences.Recaps
|
||||
}
|
||||
@@ -247,7 +231,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
mergeSTTConfig(&dst.STT, src.STT)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -264,9 +247,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
}
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
|
||||
return err
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP)
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
@@ -700,7 +681,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
mergeSTTConfig(&cfg.STT, overrides.STT)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -754,89 +734,58 @@ func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSTTConfig overlays any set field of src onto dst. Each field carries its
|
||||
// own "unset" sentinel (empty string, 0, or nil *bool), matching how the other
|
||||
// section mergers detect intent.
|
||||
func mergeSTTConfig(dst *STTConfig, src STTConfig) {
|
||||
if src.Provider != "" {
|
||||
dst.Provider = src.Provider
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
if src.StreamProvider != "" {
|
||||
dst.StreamProvider = src.StreamProvider
|
||||
if dst.Servers == nil {
|
||||
dst.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
if src.Streaming != nil {
|
||||
dst.Streaming = src.Streaming
|
||||
}
|
||||
if src.Model != "" {
|
||||
dst.Model = src.Model
|
||||
}
|
||||
if src.StreamModel != "" {
|
||||
dst.StreamModel = src.StreamModel
|
||||
}
|
||||
if src.LocalModelPath != "" {
|
||||
dst.LocalModelPath = src.LocalModelPath
|
||||
}
|
||||
if src.LocalBinary != "" {
|
||||
dst.LocalBinary = src.LocalBinary
|
||||
}
|
||||
if src.LocalServerBinary != "" {
|
||||
dst.LocalServerBinary = src.LocalServerBinary
|
||||
}
|
||||
if src.LocalServerPort != 0 {
|
||||
dst.LocalServerPort = src.LocalServerPort
|
||||
}
|
||||
if src.EngineVersion != "" {
|
||||
dst.EngineVersion = src.EngineVersion
|
||||
}
|
||||
if src.NumThreads != 0 {
|
||||
dst.NumThreads = src.NumThreads
|
||||
}
|
||||
if src.Language != "" {
|
||||
dst.Language = src.Language
|
||||
}
|
||||
if src.MaxDurationSeconds != 0 {
|
||||
dst.MaxDurationSeconds = src.MaxDurationSeconds
|
||||
}
|
||||
if src.SilenceAutoStop != nil {
|
||||
dst.SilenceAutoStop = src.SilenceAutoStop
|
||||
}
|
||||
if src.AutoSubmit != nil {
|
||||
dst.AutoSubmit = src.AutoSubmit
|
||||
}
|
||||
if src.WindowsAudioDevice != "" {
|
||||
dst.WindowsAudioDevice = src.WindowsAudioDevice
|
||||
for name, server := range src.Servers {
|
||||
dst.Servers[name] = mergeMCPServer(dst.Servers[name], server)
|
||||
}
|
||||
}
|
||||
|
||||
// validateSTTConfig rejects unknown provider/streamProvider values and a
|
||||
// negative maxDurationSeconds at load time — a clear startup error naming the
|
||||
// bad value and the valid options, never a silent fallback the user did not ask
|
||||
// for (§11a).
|
||||
func validateSTTConfig(cfg STTConfig) error {
|
||||
if cfg.Provider != "" {
|
||||
switch cfg.Provider {
|
||||
case STTProviderLocal, STTProviderGroq, STTProviderOpenAI:
|
||||
default:
|
||||
return fmt.Errorf("invalid stt.provider %q: expected local, groq, or openai", cfg.Provider)
|
||||
}
|
||||
func mergeMCPServer(base MCPServerConfig, next MCPServerConfig) MCPServerConfig {
|
||||
if strings.TrimSpace(next.Type) != "" {
|
||||
base.Type = next.Type
|
||||
}
|
||||
if cfg.StreamProvider != "" {
|
||||
switch cfg.StreamProvider {
|
||||
case STTProviderLocal, STTProviderDeepgram, STTProviderOpenAI:
|
||||
default:
|
||||
return fmt.Errorf("invalid stt.streamProvider %q: expected local, deepgram, or openai", cfg.StreamProvider)
|
||||
}
|
||||
if strings.TrimSpace(next.Command) != "" {
|
||||
base.Command = next.Command
|
||||
}
|
||||
if cfg.MaxDurationSeconds < 0 {
|
||||
return fmt.Errorf("invalid stt.maxDurationSeconds %d: must be >= 0 (0 uses the default)", cfg.MaxDurationSeconds)
|
||||
if next.Args != nil {
|
||||
base.Args = append([]string{}, next.Args...)
|
||||
}
|
||||
if cfg.NumThreads < 0 {
|
||||
return fmt.Errorf("invalid stt.numThreads %d: must be >= 0 (0 uses the engine default)", cfg.NumThreads)
|
||||
if next.Env != nil {
|
||||
base.Env = copyMCPStringMap(next.Env)
|
||||
}
|
||||
if cfg.LocalServerPort < 0 || cfg.LocalServerPort > 65535 {
|
||||
return fmt.Errorf("invalid stt.localServerPort %d: must be between 1 and 65535 (0 uses the default)", cfg.LocalServerPort)
|
||||
if strings.TrimSpace(next.URL) != "" {
|
||||
base.URL = next.URL
|
||||
}
|
||||
return nil
|
||||
if next.Headers != nil {
|
||||
base.Headers = copyMCPStringMap(next.Headers)
|
||||
}
|
||||
if strings.TrimSpace(next.Auth) != "" {
|
||||
base.Auth = next.Auth
|
||||
}
|
||||
if next.OAuth != nil {
|
||||
base.OAuth = next.OAuth
|
||||
}
|
||||
if next.disabledSet || next.Disabled {
|
||||
base.Disabled = next.Disabled
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func copyMCPStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copied[key] = value
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func hasProviderFields(profile ProviderProfile) bool {
|
||||
|
||||
@@ -127,79 +127,6 @@ func TestResolveLoadsFavoriteModelsFromUserConfigOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"activeProvider": "user",
|
||||
"providers": [{
|
||||
"name": "user",
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-user",
|
||||
"model": "gpt-user"
|
||||
}],
|
||||
"preferences": {
|
||||
"recentModels": [
|
||||
{"provider": "openrouter", "model": " google/gemini-2.5-pro "},
|
||||
{"provider": "openrouter", "model": "minimax/minimax-m2.1"}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"preferences": {
|
||||
"recentModels": [{"provider": "project", "model": "project-model"}]
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
want := []RecentModelEntry{
|
||||
{Provider: "openrouter", Model: "google/gemini-2.5-pro"},
|
||||
{Provider: "openrouter", Model: "minimax/minimax-m2.1"},
|
||||
}
|
||||
if !reflect.DeepEqual(resolved.Preferences.RecentModels, want) {
|
||||
t.Fatalf("RecentModels = %#v, want %#v", resolved.Preferences.RecentModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
// recentModels must never be merged from project config (same posture as
|
||||
// favoriteModels): a project setting it with no corresponding user setting
|
||||
// should leave the resolved value empty, not pick up the project's list.
|
||||
func TestResolveIgnoresProjectOnlyRecentModels(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"activeProvider": "user",
|
||||
"providers": [{
|
||||
"name": "user",
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-user",
|
||||
"model": "gpt-user"
|
||||
}]
|
||||
}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"preferences": {
|
||||
"recentModels": [{"provider": "project", "model": "project-model"}]
|
||||
}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if len(resolved.Preferences.RecentModels) != 0 {
|
||||
t.Fatalf("RecentModels = %#v, want empty (project-only recentModels must not be merged)", resolved.Preferences.RecentModels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoadsProviderCatalogSnakeAndCamelJSONFields(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "snake",
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetSTTLocalEngineResetsStreamProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/config.json"
|
||||
// Pre-seed a cloud streaming provider — the situation the fix guards against.
|
||||
if err := os.WriteFile(path, []byte(`{"stt":{"streamProvider":"deepgram"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := SetSTTLocalEngine(path, "sherpa-onnx-offline", "sherpa-onnx-online-websocket-server", "/models/kroko", true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetSTTLocalEngine: %v", err)
|
||||
}
|
||||
// Switching to a local model must point BOTH pipelines at the local engine, or
|
||||
// the leftover cloud streamProvider keeps the live transcript on the cloud.
|
||||
if cfg.STT.Provider != STTProviderLocal || cfg.STT.StreamProvider != STTProviderLocal {
|
||||
t.Errorf("both providers should be local, got provider=%q streamProvider=%q", cfg.STT.Provider, cfg.STT.StreamProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsUnknownProvider(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"assemblyai"}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue for unknown stt.provider")
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "assemblyai") || !strings.Contains(issues[0].Message, "local, groq, or openai") {
|
||||
t.Errorf("issue message = %q, want it to name the bad value and valid options", issues[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsUnknownStreamProvider(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"streamProvider":"groq"}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue: groq has no streaming product, not a valid streamProvider")
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "deepgram") {
|
||||
t.Errorf("issue message = %q", issues[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigAcceptsValid(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"provider":"groq","streamProvider":"deepgram","maxDurationSeconds":120}}`))
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("unexpected issues: %+v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSTTConfigRejectsNegativeDuration(t *testing.T) {
|
||||
_, issues := ValidateBytes([]byte(`{"stt":{"maxDurationSeconds":-5}}`))
|
||||
if len(issues) == 0 {
|
||||
t.Fatal("expected an issue for negative maxDurationSeconds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigDefaults(t *testing.T) {
|
||||
cfg := STTConfig{}
|
||||
if cfg.STTProvider() != STTProviderLocal {
|
||||
t.Errorf("default provider = %q, want local", cfg.STTProvider())
|
||||
}
|
||||
if cfg.STTStreamProvider() != STTProviderLocal {
|
||||
t.Errorf("default streamProvider = %q, want local", cfg.STTStreamProvider())
|
||||
}
|
||||
if !cfg.StreamingEnabled() {
|
||||
t.Error("streaming should default on")
|
||||
}
|
||||
if !cfg.SilenceAutoStopEnabled() {
|
||||
t.Error("silence auto-stop should default on")
|
||||
}
|
||||
if cfg.AutoSubmitEnabled() {
|
||||
t.Error("auto-submit should default off (insert-for-review is the safety net)")
|
||||
}
|
||||
if !cfg.Empty() {
|
||||
t.Error("zero-value STTConfig should be Empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigTriStateBools(t *testing.T) {
|
||||
cfg, issues := ValidateBytes([]byte(`{"stt":{"autoSubmit":true,"streaming":false,"silenceAutoStop":false}}`))
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("unexpected issues: %+v", issues)
|
||||
}
|
||||
if !cfg.STT.AutoSubmitEnabled() {
|
||||
t.Error("autoSubmit:true should be enabled")
|
||||
}
|
||||
if cfg.STT.StreamingEnabled() {
|
||||
t.Error("streaming:false should be disabled")
|
||||
}
|
||||
if cfg.STT.SilenceAutoStopEnabled() {
|
||||
t.Error("silenceAutoStop:false should be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTTConfigRoundTripsThroughMarshal(t *testing.T) {
|
||||
cfg, _ := ValidateBytes([]byte(`{"stt":{"provider":"groq","model":"whisper-large-v3-turbo","localServerPort":6007}}`))
|
||||
data, err := cfg.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"stt"`) {
|
||||
t.Errorf("marshaled config dropped stt section: %s", data)
|
||||
}
|
||||
reparsed, issues := ValidateBytes(data)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("re-parse issues: %+v", issues)
|
||||
}
|
||||
if reparsed.STT.Provider != STTProviderGroq || reparsed.STT.Model != "whisper-large-v3-turbo" || reparsed.STT.LocalServerPort != 6007 {
|
||||
t.Errorf("round-trip lost fields: %+v", reparsed.STT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSTTModelPersists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/config.json"
|
||||
// Cloud provider stores under stt.model.
|
||||
if _, err := SetSTTModel(path, STTProviderGroq, "whisper-large-v3-turbo"); err != nil {
|
||||
t.Fatalf("SetSTTModel: %v", err)
|
||||
}
|
||||
cfg, issues := ValidateFile(path)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("issues: %+v", issues)
|
||||
}
|
||||
if cfg.STT.Provider != STTProviderGroq || cfg.STT.Model != "whisper-large-v3-turbo" {
|
||||
t.Errorf("persisted STT = %+v", cfg.STT)
|
||||
}
|
||||
// Local provider stores under stt.localModelPath.
|
||||
if _, err := SetSTTModel(path, STTProviderLocal, "/models/moonshine"); err != nil {
|
||||
t.Fatalf("SetSTTModel local: %v", err)
|
||||
}
|
||||
cfg, _ = ValidateFile(path)
|
||||
if cfg.STT.LocalModelPath != "/models/moonshine" {
|
||||
t.Errorf("local model path = %q", cfg.STT.LocalModelPath)
|
||||
}
|
||||
}
|
||||
+10
-167
@@ -97,11 +97,6 @@ type ToolsConfig struct {
|
||||
|
||||
type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
// RecentModels is the short automatic history of provider+model pairs the
|
||||
// user has switched to via /model, newest first. Unlike FavoriteModels
|
||||
// (manual pins), this list is maintained automatically on every switch and
|
||||
// capped to MaxRecentModels entries. See RecentModelEntry.
|
||||
RecentModels []RecentModelEntry `json:"recentModels,omitempty"`
|
||||
// Theme is the persisted TUI palette preference — "auto" or a registered theme
|
||||
// name (e.g. "dracula"). Applied at startup below the --theme flag and
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
@@ -112,18 +107,6 @@ type PreferencesConfig struct {
|
||||
Recaps *bool `json:"recaps,omitempty"`
|
||||
}
|
||||
|
||||
// RecentModelEntry is one provider-qualified model selection recorded in
|
||||
// Preferences.RecentModels. Provider is the saved provider profile's Name (not
|
||||
// a display label), so a recent entry can be resolved back to a concrete
|
||||
// profile the same way the /model picker's cross-provider rows are.
|
||||
type RecentModelEntry struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// MaxRecentModels caps the persisted/displayed recent-selection history.
|
||||
const MaxRecentModels = 5
|
||||
|
||||
// RecapsEnabled reports whether post-turn recaps are on. Unset defaults to ON.
|
||||
func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
@@ -148,115 +131,6 @@ type KeyBindingsConfig struct {
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
}
|
||||
|
||||
// STTProviderKind is the batch (or streaming) transcription backend selector.
|
||||
// Validated at config load time against the known set, so a typo fails loudly
|
||||
// with the valid options rather than silently falling back to a default.
|
||||
type STTProviderKind string
|
||||
|
||||
const (
|
||||
STTProviderLocal STTProviderKind = "local"
|
||||
STTProviderGroq STTProviderKind = "groq"
|
||||
STTProviderOpenAI STTProviderKind = "openai"
|
||||
STTProviderDeepgram STTProviderKind = "deepgram"
|
||||
)
|
||||
|
||||
// STTConfig configures speech-to-text dictation. All fields are optional; empty
|
||||
// values take the documented defaults. Booleans that need a real tri-state
|
||||
// (distinguishing "unset" from "false") use *bool, matching PreferencesConfig.Recaps.
|
||||
type STTConfig struct {
|
||||
// Provider is the batch transcription backend: "local" (sherpa-onnx-offline,
|
||||
// the default), "groq", or "openai". Termux and the fallback everywhere use
|
||||
// this path.
|
||||
Provider STTProviderKind `json:"provider,omitempty"`
|
||||
// StreamProvider is the streaming backend on desktop: "local" (sherpa-onnx
|
||||
// websocket server, default), "deepgram", or "openai" (Realtime).
|
||||
StreamProvider STTProviderKind `json:"streamProvider,omitempty"`
|
||||
// Streaming enables the live-transcript pipeline on platforms that support
|
||||
// it (desktop). Defaults to on; set false to always use the batch pipeline.
|
||||
Streaming *bool `json:"streaming,omitempty"`
|
||||
// Model overrides the cloud batch model (e.g. "whisper-large-v3-turbo" for
|
||||
// Groq, "whisper-1" for OpenAI). Empty uses the provider default.
|
||||
Model string `json:"model,omitempty"`
|
||||
// StreamModel overrides the cloud streaming model (Deepgram/OpenAI Realtime).
|
||||
StreamModel string `json:"streamModel,omitempty"`
|
||||
// LocalModelPath is the sherpa-onnx model directory for local transcription
|
||||
// (batch and streaming). Required to use a local provider.
|
||||
LocalModelPath string `json:"localModelPath,omitempty"`
|
||||
// LocalBinary overrides the offline binary name/path (default
|
||||
// "sherpa-onnx-offline"); LocalServerBinary overrides the streaming server
|
||||
// (default "sherpa-onnx-online-websocket-server"). Both looked up on PATH.
|
||||
LocalBinary string `json:"localBinary,omitempty"`
|
||||
LocalServerBinary string `json:"localServerBinary,omitempty"`
|
||||
// LocalServerPort is the localhost port for the sherpa-onnx websocket server
|
||||
// (default 6006).
|
||||
LocalServerPort int `json:"localServerPort,omitempty"`
|
||||
// EngineVersion selects the sherpa-onnx release the auto-download fetches
|
||||
// ("" → a pinned known-good default; "latest" or any release tag also works,
|
||||
// so a newer engine needs no Zero update). Verified against the release's
|
||||
// published SHA256 digest either way.
|
||||
EngineVersion string `json:"engineVersion,omitempty"`
|
||||
// NumThreads sets the local engine's thread count (0 = engine default).
|
||||
NumThreads int `json:"numThreads,omitempty"`
|
||||
// Language optionally constrains recognition (ISO-639-1, e.g. "en"). Empty =
|
||||
// auto-detect.
|
||||
Language string `json:"language,omitempty"`
|
||||
// MaxDurationSeconds is the hard runaway-recording cap on every platform
|
||||
// (0 = default 300s).
|
||||
MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"`
|
||||
// SilenceAutoStop ends a recording ~2s after the signal goes quiet, as a
|
||||
// backstop for a forgotten manual stop (not VAD-triggered start). Defaults on.
|
||||
SilenceAutoStop *bool `json:"silenceAutoStop,omitempty"`
|
||||
// AutoSubmit fires the transcript at the agent instead of inserting it for
|
||||
// review. Defaults OFF — insert-for-review is the safety net for a misheard
|
||||
// prompt (§3).
|
||||
AutoSubmit *bool `json:"autoSubmit,omitempty"`
|
||||
// WindowsAudioDevice names the dshow capture device (auto-detected when empty).
|
||||
WindowsAudioDevice string `json:"windowsAudioDevice,omitempty"`
|
||||
}
|
||||
|
||||
// STTProvider returns the configured batch provider, defaulting to local.
|
||||
func (c STTConfig) STTProvider() STTProviderKind {
|
||||
if c.Provider == "" {
|
||||
return STTProviderLocal
|
||||
}
|
||||
return c.Provider
|
||||
}
|
||||
|
||||
// STTStreamProvider returns the configured streaming provider, defaulting to local.
|
||||
func (c STTConfig) STTStreamProvider() STTProviderKind {
|
||||
if c.StreamProvider == "" {
|
||||
return STTProviderLocal
|
||||
}
|
||||
return c.StreamProvider
|
||||
}
|
||||
|
||||
// StreamingEnabled reports whether the live pipeline is on. Unset defaults ON.
|
||||
func (c STTConfig) StreamingEnabled() bool {
|
||||
return c.Streaming == nil || *c.Streaming
|
||||
}
|
||||
|
||||
// SilenceAutoStopEnabled reports whether trailing-silence auto-stop is on.
|
||||
// Unset defaults ON.
|
||||
func (c STTConfig) SilenceAutoStopEnabled() bool {
|
||||
return c.SilenceAutoStop == nil || *c.SilenceAutoStop
|
||||
}
|
||||
|
||||
// AutoSubmitEnabled reports whether transcripts are auto-fired. Unset defaults OFF.
|
||||
func (c STTConfig) AutoSubmitEnabled() bool {
|
||||
return c.AutoSubmit != nil && *c.AutoSubmit
|
||||
}
|
||||
|
||||
// Empty reports whether the STT config carries no user-set values (so it can be
|
||||
// omitted from a marshaled config).
|
||||
func (c STTConfig) Empty() bool {
|
||||
return c.Provider == "" && c.StreamProvider == "" && c.Streaming == nil &&
|
||||
c.Model == "" && c.StreamModel == "" && c.LocalModelPath == "" &&
|
||||
c.LocalBinary == "" && c.LocalServerBinary == "" && c.LocalServerPort == 0 &&
|
||||
c.EngineVersion == "" && c.NumThreads == 0 && c.Language == "" &&
|
||||
c.MaxDurationSeconds == 0 && c.SilenceAutoStop == nil && c.AutoSubmit == nil &&
|
||||
c.WindowsAudioDevice == ""
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
@@ -353,7 +227,6 @@ type FileConfig struct {
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
@@ -369,7 +242,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
STT *STTConfig `json:"stt,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
@@ -386,9 +258,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
}
|
||||
if !cfg.STT.Empty() {
|
||||
raw.STT = &cfg.STT
|
||||
}
|
||||
return json.Marshal(raw)
|
||||
}
|
||||
|
||||
@@ -398,11 +267,6 @@ type ResolveOptions struct {
|
||||
ProviderCommand string
|
||||
Env map[string]string
|
||||
Overrides Overrides
|
||||
// ExcludeProject drops the project config layer (ProjectConfigPath) from MCP
|
||||
// resolution when the workspace is untrusted, so a cloned repo's ./.zero/config.json
|
||||
// cannot spawn stdio MCP servers. It is fail-closed: only a trusted workspace sets
|
||||
// it false. Mirrors the ExcludeProject option hooks and plugins already honor.
|
||||
ExcludeProject bool
|
||||
}
|
||||
|
||||
type Overrides struct {
|
||||
@@ -416,7 +280,6 @@ type Overrides struct {
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
@@ -432,7 +295,6 @@ type ResolvedConfig struct {
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
STT STTConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -440,29 +302,16 @@ type MCPConfig struct {
|
||||
}
|
||||
|
||||
type MCPServerConfig struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// ProjectConfigured marks servers touched by project config. It is runtime
|
||||
// metadata, not persisted config.
|
||||
ProjectConfigured bool `json:"-"`
|
||||
disabledSet bool
|
||||
// configured is true when the user's config JSON declared an object for
|
||||
// this server at all (i.e. UnmarshalJSON ran for it), regardless of which
|
||||
// fields it set or what values they hold. A built-in default seeded by
|
||||
// DefaultMCPServers() is never unmarshaled from JSON, so it starts false;
|
||||
// any explicit entry in the user/project file — even one that happens to
|
||||
// repeat a default's exact field values (e.g. re-declaring firecrawl's
|
||||
// default URL) — sets it true. IsUnconfiguredDefault checks this alongside
|
||||
// a resolved-value comparison, so redeclaring default values verbatim still
|
||||
// counts as user-configured.
|
||||
configured bool
|
||||
Type string `json:"type,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
OAuth *MCPOAuthConfig `json:"oauth,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
disabledSet bool
|
||||
}
|
||||
|
||||
// MCPOAuthConfig describes how to authenticate to a remote MCP server using an
|
||||
@@ -493,7 +342,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
STT STTConfig `json:"stt"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
}
|
||||
@@ -521,7 +369,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
cfg.STT = raw.STT
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
}
|
||||
@@ -656,10 +503,6 @@ func (server *MCPServerConfig) UnmarshalJSON(data []byte) error {
|
||||
server.Disabled = *raw.Disabled
|
||||
server.disabledSet = true
|
||||
}
|
||||
// This method only runs when the user's JSON actually has an object for this
|
||||
// server key (built-in defaults are seeded as Go struct literals, never
|
||||
// unmarshaled), so reaching here at all means the user configured it.
|
||||
server.configured = true
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,5 @@ func validateSemantics(cfg FileConfig) []Issue {
|
||||
// normalizeProviders already redacts secrets via providerError.
|
||||
return []Issue{{FieldPath: "providers", Message: err.Error()}}
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return []Issue{{FieldPath: "stt", Message: err.Error()}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
||||
@@ -31,20 +29,6 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
|
||||
}
|
||||
|
||||
mergeProvider(&cfg, profile)
|
||||
// mergeProfile deliberately ignores APIKeyStored — during resolve-time
|
||||
// layering a project config must not be able to claim the user's stored
|
||||
// keys. This user-config WRITE path re-applies the marker: capturing a key
|
||||
// via SecureProviderProfile onto a previously env/no-key profile must
|
||||
// persist apiKeyStored, or the secret sits in the credential store while
|
||||
// every ApplyStoredAPIKey gate skips it (PR #560 review).
|
||||
if profile.APIKeyStored {
|
||||
for index := range cfg.Providers {
|
||||
if cfg.Providers[index].Name == profile.Name {
|
||||
cfg.Providers[index].APIKeyStored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
|
||||
cfg.ActiveProvider = profile.Name
|
||||
}
|
||||
@@ -55,95 +39,6 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name
|
||||
// that serves the catalog entry, whether it was newly created, and which provider
|
||||
// is active after the call (unchanged unless it was blank).
|
||||
type EnsuredProvider struct {
|
||||
Name string
|
||||
Created bool
|
||||
Active string
|
||||
}
|
||||
|
||||
// EnsureCatalogProvider guarantees a provider profile exists in the config at
|
||||
// path for the given catalog entry. OAuth login flows call this right after
|
||||
// storing a token: a login is only reachable from the provider list and
|
||||
// `zero providers use` when a profile exists, but a login must never replace or
|
||||
// deactivate the user's current active provider — so an existing profile whose
|
||||
// Name or CatalogID already matches is left completely untouched (its name,
|
||||
// credentials, and model are the user's), and a created profile is NOT marked
|
||||
// active unless no provider was active at all.
|
||||
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return EnsuredProvider{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
descriptor, err := providercatalog.Require(catalogID)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return EnsuredProvider{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
for _, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) ||
|
||||
strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) {
|
||||
return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil
|
||||
}
|
||||
}
|
||||
|
||||
profile := ProviderProfile{
|
||||
Name: descriptor.ID,
|
||||
ProviderKind: providerKindForCatalogTransport(descriptor.Transport),
|
||||
CatalogID: descriptor.ID,
|
||||
BaseURL: descriptor.DefaultBaseURL,
|
||||
Model: descriptor.DefaultModel,
|
||||
}
|
||||
written, err := UpsertProvider(path, profile, false)
|
||||
if err != nil {
|
||||
return EnsuredProvider{}, err
|
||||
}
|
||||
return EnsuredProvider{Name: profile.Name, Created: true, Active: written.ActiveProvider}, nil
|
||||
}
|
||||
|
||||
// MarkProviderAPIKeyStored records that provider's API key now lives in the
|
||||
// credential store. It also clears inline/env key fields so the stored key is the
|
||||
// runtime credential; an old apiKeyEnv value must not keep overriding a freshly
|
||||
// captured key from `zero auth openrouter` or provider setup.
|
||||
func MarkProviderAPIKeyStored(path string, provider string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("config path is required")
|
||||
}
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
return fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), provider) {
|
||||
cfg.Providers[index].APIKey = ""
|
||||
cfg.Providers[index].APIKeyEnv = ""
|
||||
cfg.Providers[index].APIKeyStored = true
|
||||
return writeConfigFile(path, cfg)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("provider %q not found", provider)
|
||||
}
|
||||
|
||||
func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -177,296 +72,6 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// RemoveProvider deletes the named provider profile from the config at path.
|
||||
// When the removed profile was active, activeProvider hands off to the first
|
||||
// remaining provider (or clears when none remain) so the config never points at
|
||||
// a profile that no longer exists. The caller owns cleaning up the credential
|
||||
// store entry — config stays pure of secret I/O on the read path, and remove
|
||||
// keeps that symmetry by only touching config.json.
|
||||
func RemoveProvider(path string, name string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
removed := cfg.Providers[index]
|
||||
cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...)
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) {
|
||||
cfg.ActiveProvider = ""
|
||||
if len(cfg.Providers) > 0 {
|
||||
cfg.ActiveProvider = cfg.Providers[0].Name
|
||||
}
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RenameProvider renames a provider profile, keeping everything keyed by the
|
||||
// profile name consistent: the activeProvider pointer follows the rename, and a
|
||||
// key in the encrypted credential store (APIKeyStored) is migrated to the new
|
||||
// name BEFORE the config is rewritten — the store write must succeed first so a
|
||||
// failed migration never strands the config pointing at a key that no longer
|
||||
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
|
||||
// candidates fall back to the profile's CatalogID, which every OAuth-capable
|
||||
// catalog profile carries, so a rename keeps the login reachable.
|
||||
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
oldName = strings.TrimSpace(oldName)
|
||||
newName = strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider names are required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
providerName := strings.TrimSpace(provider.Name)
|
||||
if strings.EqualFold(providerName, oldName) {
|
||||
index = i
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerName, newName) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
|
||||
}
|
||||
if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
previousName := cfg.Providers[index].Name
|
||||
keyMigrated := false
|
||||
if cfg.Providers[index].APIKeyStored {
|
||||
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
|
||||
}
|
||||
keyMigrated = true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
|
||||
cfg.ActiveProvider = newName
|
||||
}
|
||||
cfg.Providers[index].Name = newName
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
if keyMigrated {
|
||||
// Compensate best-effort: config.json still names the OLD profile, so
|
||||
// move the key back where that config can find it — otherwise a failed
|
||||
// rewrite strands the key under a name no profile carries.
|
||||
_ = migrateStoredProviderKey(path, newName, previousName)
|
||||
}
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ProviderEdit is a field-level edit of one saved provider, applied by
|
||||
// EditProvider in a single atomic write. Name is the CURRENT profile name
|
||||
// (matched case-insensitively); NewName renames (case-only renames included).
|
||||
// Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied
|
||||
// VERBATIM (the editor always knows the full desired text, so clearing works).
|
||||
type ProviderEdit struct {
|
||||
Name string
|
||||
NewName string
|
||||
BaseURL string
|
||||
Model string
|
||||
APIKey string
|
||||
APIKeyStored bool
|
||||
Description string
|
||||
}
|
||||
|
||||
// EditProvider applies a provider edit in ONE read-modify-write: rename
|
||||
// (activeProvider follows; a stored key migrates, with a best-effort rollback
|
||||
// if the config write fails), field updates, the stored-key marker, and the
|
||||
// verbatim description. A single write keeps the operation atomic — the
|
||||
// previous rename+upsert+describe sequence could fail halfway and leave
|
||||
// config.json renamed while every in-memory consumer still held the old name —
|
||||
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
|
||||
// here makes a case-only rename (groq -> Groq) an in-place update instead of
|
||||
// an appended duplicate profile.
|
||||
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
oldName := strings.TrimSpace(edit.Name)
|
||||
if oldName == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
newName := strings.TrimSpace(edit.NewName)
|
||||
if newName == "" {
|
||||
newName = oldName
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i, provider := range cfg.Providers {
|
||||
providerName := strings.TrimSpace(provider.Name)
|
||||
if strings.EqualFold(providerName, oldName) {
|
||||
index = i
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerName, newName) {
|
||||
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
|
||||
}
|
||||
|
||||
previousName := cfg.Providers[index].Name
|
||||
renamed := previousName != newName
|
||||
keyMigrated := false
|
||||
// A rename moves the stored key along: either the profile's existing entry,
|
||||
// or a replacement key the caller just captured — the contract is that a
|
||||
// captured key is stored under the CURRENT name before EditProvider runs, so
|
||||
// one migration covers both. migrateStoredProviderKey no-ops on case-only
|
||||
// renames (the store normalizes names), so it cannot delete the key it just
|
||||
// moved.
|
||||
if renamed && (cfg.Providers[index].APIKeyStored || edit.APIKeyStored) {
|
||||
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
|
||||
}
|
||||
keyMigrated = true
|
||||
}
|
||||
if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
|
||||
cfg.ActiveProvider = newName
|
||||
}
|
||||
|
||||
profile := &cfg.Providers[index]
|
||||
profile.Name = newName
|
||||
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
|
||||
profile.BaseURL = baseURL
|
||||
}
|
||||
if model := strings.TrimSpace(edit.Model); model != "" {
|
||||
profile.Model = model
|
||||
}
|
||||
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
|
||||
profile.APIKey = apiKey
|
||||
}
|
||||
if edit.APIKeyStored {
|
||||
profile.APIKeyStored = true
|
||||
}
|
||||
profile.Description = strings.TrimSpace(edit.Description)
|
||||
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
if keyMigrated {
|
||||
// Compensate best-effort: config.json still names the OLD profile, so
|
||||
// move the key back where that config can find it.
|
||||
_ = migrateStoredProviderKey(path, newName, previousName)
|
||||
}
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetProviderDescription sets a provider's description VERBATIM — including to
|
||||
// empty. The generic UpsertProvider merge treats empty fields as "leave
|
||||
// unchanged", so clearing a description needs this dedicated setter.
|
||||
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return FileConfig{}, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
for index := range cfg.Providers {
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
|
||||
cfg.Providers[index].Description = strings.TrimSpace(description)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("provider %q not found", name)
|
||||
}
|
||||
|
||||
// migrateStoredProviderKey moves a credential-store entry to a new provider
|
||||
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
|
||||
// never a missing key. A missing source entry is a no-op (the marker may be
|
||||
// stale); only a failed WRITE aborts the rename.
|
||||
func migrateStoredProviderKey(configPath string, oldName string, newName string) error {
|
||||
// The store normalizes names case-insensitively, so a case-only rename
|
||||
// (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and
|
||||
// Delete(old) would then remove the key that was just "moved". Nothing to
|
||||
// migrate — the existing entry already serves the new name.
|
||||
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) {
|
||||
return nil
|
||||
}
|
||||
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok, err := store.Get(oldName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || strings.TrimSpace(key) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := store.Set(newName, key); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = store.Delete(oldName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -526,32 +131,6 @@ func SetFavoriteModels(path string, models []string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetRecentModels persists the automatic recent-model-switch history,
|
||||
// mirroring SetFavoriteModels (read-modify-atomic-write). Unlike favorites,
|
||||
// order is preserved (newest first) rather than sorted, since it reflects
|
||||
// switch recency, not an alphabetical preference list.
|
||||
func SetRecentModels(path string, entries []RecentModelEntry) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg.Preferences.RecentModels = NormalizeRecentModels(entries)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetRecapsEnabled persists the post-turn recap preference, mirroring
|
||||
// SetFavoriteModels (read-modify-atomic-write).
|
||||
func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
@@ -597,106 +176,6 @@ func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTModel persists the dictation model and its provider, mirroring
|
||||
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
|
||||
// provider kinds; a local provider stores the model as stt.localModelPath,
|
||||
// otherwise as stt.model. A blank model clears the stored value for that slot.
|
||||
func SetSTTModel(path string, provider STTProviderKind, model string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
if provider != "" {
|
||||
cfg.STT.Provider = provider
|
||||
}
|
||||
model = strings.TrimSpace(model)
|
||||
if cfg.STT.STTProvider() == STTProviderLocal {
|
||||
cfg.STT.LocalModelPath = model
|
||||
} else {
|
||||
cfg.STT.Model = model
|
||||
}
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTLocalEngine persists the paths of an auto-downloaded local engine +
|
||||
// model and switches dictation to the local provider, mirroring SetTheme
|
||||
// (read-modify-atomic-write). streaming selects the pipeline matching the
|
||||
// downloaded model (a streaming transducer vs a batch model). Called after a
|
||||
// download completes.
|
||||
func SetSTTLocalEngine(path, binary, serverBinary, modelPath string, streaming bool) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.STT.Provider = STTProviderLocal
|
||||
// Point BOTH pipelines at the local engine. Without resetting StreamProvider, a
|
||||
// previously-chosen cloud value (deepgram/openai) would still win in
|
||||
// buildStreamingTranscriber, so the live transcript would keep hitting the cloud
|
||||
// after the user switched to a downloaded local model.
|
||||
cfg.STT.StreamProvider = STTProviderLocal
|
||||
cfg.STT.LocalBinary = strings.TrimSpace(binary)
|
||||
cfg.STT.LocalServerBinary = strings.TrimSpace(serverBinary)
|
||||
cfg.STT.LocalModelPath = strings.TrimSpace(modelPath)
|
||||
// Match the pipeline to the downloaded model: a streaming transducer drives
|
||||
// the websocket server for a live transcript; a batch model uses the offline
|
||||
// binary.
|
||||
s := streaming
|
||||
cfg.STT.Streaming = &s
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetSTTProvider persists just the dictation batch provider, mirroring SetTheme.
|
||||
func SetSTTProvider(path string, provider STTProviderKind) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.STT.Provider = provider
|
||||
if err := validateSTTConfig(cfg.STT); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeFavoriteModels(models []string) []string {
|
||||
seen := map[string]bool{}
|
||||
favorites := make([]string, 0, len(models))
|
||||
@@ -712,35 +191,6 @@ func normalizeFavoriteModels(models []string) []string {
|
||||
return favorites
|
||||
}
|
||||
|
||||
// NormalizeRecentModels trims, drops entries with no model id, de-duplicates
|
||||
// by provider+model pair (keeping the first/newest occurrence), and caps the
|
||||
// result to MaxRecentModels. Order is preserved — the caller is responsible
|
||||
// for passing entries newest-first. Exported so callers outside this package
|
||||
// (e.g. the TUI, which keeps its own in-memory copy of recent history) apply
|
||||
// the exact same normalization rules as the persisted config, instead of
|
||||
// maintaining a second, independently-drifting copy of this logic.
|
||||
func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
|
||||
seen := map[string]bool{}
|
||||
recent := make([]RecentModelEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
provider := strings.TrimSpace(entry.Provider)
|
||||
model := strings.TrimSpace(entry.Model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(provider) + "\x00" + model
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
recent = append(recent, RecentModelEntry{Provider: provider, Model: model})
|
||||
if len(recent) >= MaxRecentModels {
|
||||
break
|
||||
}
|
||||
}
|
||||
return recent
|
||||
}
|
||||
|
||||
func writeConfigFile(path string, cfg FileConfig) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "." && dir != "" {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -273,69 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRecentModelsPersistsOrderDedupesAndCaps(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetRecentModels(path, []RecentModelEntry{
|
||||
{Provider: " openrouter ", Model: " google/gemini-2.5-pro "},
|
||||
{Provider: "openrouter", Model: "minimax/minimax-m2.1"},
|
||||
{Provider: "openrouter", Model: "google/gemini-2.5-pro"}, // duplicate pair, older: dropped
|
||||
{Provider: "", Model: ""}, // blank model: dropped
|
||||
{Provider: "openrouter", Model: "a"},
|
||||
{Provider: "openrouter", Model: "b"},
|
||||
{Provider: "openrouter", Model: "c"},
|
||||
{Provider: "openrouter", Model: "d"}, // beyond MaxRecentModels (5): dropped
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecentModels() error = %v", err)
|
||||
}
|
||||
|
||||
want := []RecentModelEntry{
|
||||
{Provider: "openrouter", Model: "google/gemini-2.5-pro"},
|
||||
{Provider: "openrouter", Model: "minimax/minimax-m2.1"},
|
||||
{Provider: "openrouter", Model: "a"},
|
||||
{Provider: "openrouter", Model: "b"},
|
||||
{Provider: "openrouter", Model: "c"},
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.Preferences.RecentModels, want) {
|
||||
t.Fatalf("RecentModels = %#v, want %#v", cfg.Preferences.RecentModels, want)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if !reflect.DeepEqual(persisted.Preferences.RecentModels, want) {
|
||||
t.Fatalf("persisted RecentModels = %#v, want %#v", persisted.Preferences.RecentModels, want)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// Two providers offering the same model id must both survive normalization —
|
||||
// recent history de-duplicates by provider+model pair, not model id alone.
|
||||
func TestSetRecentModelsDedupesByProviderAndModelPair(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
|
||||
cfg, err := SetRecentModels(path, []RecentModelEntry{
|
||||
{Provider: "provider-a", Model: "shared-model"},
|
||||
{Provider: "provider-b", Model: "shared-model"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRecentModels() error = %v", err)
|
||||
}
|
||||
want := []RecentModelEntry{
|
||||
{Provider: "provider-a", Model: "shared-model"},
|
||||
{Provider: "provider-b", Model: "shared-model"},
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.Preferences.RecentModels, want) {
|
||||
t.Fatalf("RecentModels = %#v, want both providers preserved: %#v", cfg.Preferences.RecentModels, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetThemePersistsUserPreference(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
@@ -449,575 +385,3 @@ func readConfigFixture(t *testing.T, path string) FileConfig {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderCreatesProfileWithoutStealingActive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []ProviderProfile{
|
||||
{
|
||||
Name: "opengateway",
|
||||
ProviderKind: ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://gateway.example.com/v1",
|
||||
APIKeyStored: true,
|
||||
Model: "some-model",
|
||||
},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if !ensured.Created {
|
||||
t.Fatalf("expected profile to be created")
|
||||
}
|
||||
if ensured.Name != "chatgpt" {
|
||||
t.Fatalf("expected profile name chatgpt, got %q", ensured.Name)
|
||||
}
|
||||
if ensured.Active != "opengateway" {
|
||||
t.Fatalf("active provider must stay opengateway, got %q", ensured.Active)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if cfg.ActiveProvider != "opengateway" {
|
||||
t.Fatalf("persisted active provider changed to %q", cfg.ActiveProvider)
|
||||
}
|
||||
if len(cfg.Providers) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d", len(cfg.Providers))
|
||||
}
|
||||
chatgpt := cfg.Providers[1]
|
||||
if chatgpt.Name != "chatgpt" || chatgpt.CatalogID != "chatgpt" {
|
||||
t.Fatalf("unexpected created profile: %+v", chatgpt)
|
||||
}
|
||||
if chatgpt.Model == "" || chatgpt.BaseURL == "" {
|
||||
t.Fatalf("created profile must carry catalog defaults, got %+v", chatgpt)
|
||||
}
|
||||
if chatgpt.APIKey != "" || chatgpt.APIKeyStored {
|
||||
t.Fatalf("OAuth-created profile must stay keyless, got %+v", chatgpt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderLeavesExistingProfileUntouched(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
original := FileConfig{
|
||||
ActiveProvider: "opengateway",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "opengateway", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
|
||||
// Renamed profile that already serves the chatgpt catalog entry.
|
||||
{Name: "codex", CatalogID: "chatgpt", Model: "gpt-5.5"},
|
||||
},
|
||||
}
|
||||
data := writeConfigFixture(t, path, original, 0o600)
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if ensured.Created {
|
||||
t.Fatalf("existing profile must not be recreated")
|
||||
}
|
||||
if ensured.Name != "codex" {
|
||||
t.Fatalf("expected existing profile name codex, got %q", ensured.Name)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(data) {
|
||||
t.Fatalf("config rewritten for a no-op ensure:\nbefore: %s\nafter: %s", data, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderActivatesOnEmptyConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
|
||||
ensured, err := EnsureCatalogProvider(path, "chatgpt")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCatalogProvider: %v", err)
|
||||
}
|
||||
if !ensured.Created {
|
||||
t.Fatalf("expected profile to be created")
|
||||
}
|
||||
if ensured.Active != "chatgpt" {
|
||||
t.Fatalf("blank active must adopt the new provider, got %q", ensured.Active)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCatalogProviderRejectsUnknownCatalogID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
if _, err := EnsureCatalogProvider(path, "no-such-provider"); err == nil {
|
||||
t.Fatalf("expected unknown catalog id to error")
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("config must not be written for an unknown catalog id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkProviderAPIKeyStoredClearsInlineAndEnvKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openrouter",
|
||||
Providers: []ProviderProfile{{
|
||||
Name: "openrouter",
|
||||
CatalogID: "openrouter",
|
||||
APIKey: "sk-inline",
|
||||
APIKeyEnv: "OPENROUTER_API_KEY",
|
||||
Model: "openai/gpt-4.1",
|
||||
}},
|
||||
}, 0o600)
|
||||
|
||||
if err := MarkProviderAPIKeyStored(path, "openrouter"); err != nil {
|
||||
t.Fatalf("MarkProviderAPIKeyStored: %v", err)
|
||||
}
|
||||
|
||||
cfg := readConfigFixture(t, path)
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("providers = %#v", cfg.Providers)
|
||||
}
|
||||
profile := cfg.Providers[0]
|
||||
if !profile.APIKeyStored || profile.APIKey != "" || profile.APIKeyEnv != "" {
|
||||
t.Fatalf("provider credential fields = %#v", profile)
|
||||
}
|
||||
if profile.Model != "openai/gpt-4.1" || cfg.ActiveProvider != "openrouter" {
|
||||
t.Fatalf("unrelated config changed: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "beta",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := RemoveProvider(path, " BETA ")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider() error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "alpha" {
|
||||
t.Fatalf("expected only alpha to remain, got %+v", cfg.Providers)
|
||||
}
|
||||
if cfg.ActiveProvider != "alpha" {
|
||||
t.Fatalf("active must hand off to the first remaining provider, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
|
||||
persisted := readConfigFixture(t, path)
|
||||
if len(persisted.Providers) != 1 || persisted.ActiveProvider != "alpha" {
|
||||
t.Fatalf("persisted config wrong: %+v", persisted)
|
||||
}
|
||||
|
||||
// Removing the last provider clears the active pointer entirely.
|
||||
cfg, err = RemoveProvider(path, "alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider(last) error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 0 || cfg.ActiveProvider != "" {
|
||||
t.Fatalf("expected empty providers and no active, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderKeepsActiveWhenOtherRemoved(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := RemoveProvider(path, "beta")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveProvider() error = %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "alpha" {
|
||||
t.Fatalf("active provider must be untouched, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProviderRejectsUnknownWithoutRewriting(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := RemoveProvider(path, "ghost"); err == nil {
|
||||
t.Fatal("RemoveProvider() error = nil, want not-found error")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten for unknown provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderFollowsActiveAndMigratesStoredKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Force the file credential backend so the test never touches the real OS
|
||||
// keyring regardless of platform.
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "oldname",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
|
||||
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("oldname", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := RenameProvider(path, "oldname", "newname")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameProvider() error = %v", err)
|
||||
}
|
||||
if cfg.ActiveProvider != "newname" {
|
||||
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if cfg.Providers[0].Name != "newname" || !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("renamed profile wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[1].Name != "other" {
|
||||
t.Fatalf("unrelated profile changed: %+v", cfg.Providers[1])
|
||||
}
|
||||
|
||||
key, ok, err := store.Get("newname")
|
||||
if err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key must migrate to the new name, got key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
if _, ok, _ := store.Get("oldname"); ok {
|
||||
t.Fatalf("old credential entry must be deleted after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := RenameProvider(path, "alpha", "BETA"); err == nil {
|
||||
t.Fatal("rename onto an existing name must fail")
|
||||
}
|
||||
if _, err := RenameProvider(path, "ghost", "gamma"); err == nil {
|
||||
t.Fatal("renaming an unknown provider must fail")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten by a rejected rename")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProviderPreservesStoredKeyMarkerOnExistingProfile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
// An env-keyed profile with NO stored-key marker — the shape a provider has
|
||||
// before its key is captured into the credential store.
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY", Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
// The manager/setup edit paths persist a SecureProviderProfile-shaped
|
||||
// profile: key already in the store, marker set, inline key cleared.
|
||||
cfg, err := UpsertProvider(path, ProviderProfile{Name: "groq", APIKeyStored: true}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertProvider() error = %v", err)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("APIKeyStored marker must survive the merge, got %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[0].APIKeyEnv != "GROQ_API_KEY" || cfg.Providers[0].BaseURL == "" {
|
||||
t.Fatalf("other fields must be preserved: %+v", cfg.Providers[0])
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if !persisted.Providers[0].APIKeyStored {
|
||||
t.Fatalf("marker not persisted to disk: %+v", persisted.Providers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderDescriptionSetsAndClears(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1", Description: "old text"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetProviderDescription(path, " ALPHA ", "new text")
|
||||
if err != nil {
|
||||
t.Fatalf("SetProviderDescription() error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Description != "new text" {
|
||||
t.Fatalf("description not set: %+v", cfg.Providers[0])
|
||||
}
|
||||
|
||||
// Clearing must persist too — the reason this setter exists (UpsertProvider's
|
||||
// merge treats an empty description as "leave unchanged").
|
||||
cfg, err = SetProviderDescription(path, "alpha", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetProviderDescription(clear) error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Description != "" {
|
||||
t.Fatalf("description not cleared: %+v", cfg.Providers[0])
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Providers[0].Description != "" {
|
||||
t.Fatalf("cleared description not persisted: %+v", persisted.Providers[0])
|
||||
}
|
||||
|
||||
if _, err := SetProviderDescription(path, "ghost", "x"); err == nil {
|
||||
t.Fatal("unknown provider must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProviderRollsBackKeyMigrationWhenConfigWriteFails(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("uses chflags uchg to force the config write to fail; macOS only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "oldname",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("oldname", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
// Immutable flag: temp-file creation and store writes in the directory keep
|
||||
// working, but the final rename over config.json fails — the exact window
|
||||
// where a migrated key would otherwise strand under the new name.
|
||||
if out, err := exec.Command("chflags", "uchg", path).CombinedOutput(); err != nil {
|
||||
t.Skipf("chflags uchg unavailable: %v (%s)", err, out)
|
||||
}
|
||||
t.Cleanup(func() { _ = exec.Command("chflags", "nouchg", path).Run() })
|
||||
|
||||
if _, err := RenameProvider(path, "oldname", "newname"); err == nil {
|
||||
t.Fatal("expected the config write to fail under the immutable flag")
|
||||
}
|
||||
|
||||
key, ok, err := store.Get("oldname")
|
||||
if err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("key must be rolled back to the old name, got key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
if _, ok, _ := store.Get("newname"); ok {
|
||||
t.Fatalf("rolled-back migration must not leave a key under the new name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenameProviderCaseOnlyKeepsStoredKey: the credential store normalizes
|
||||
// names case-insensitively, so a case-only rename targets ONE store entry —
|
||||
// migrating would Set and then Delete the same key, losing it (PR #560 review).
|
||||
func TestRenameProviderCaseOnlyKeepsStoredKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := RenameProvider(path, "groq", "Groq")
|
||||
if err != nil {
|
||||
t.Fatalf("RenameProvider() error = %v", err)
|
||||
}
|
||||
if cfg.Providers[0].Name != "Groq" || cfg.ActiveProvider != "Groq" {
|
||||
t.Fatalf("case-only rename must still apply to config: %+v", cfg)
|
||||
}
|
||||
// The store is case-insensitive: the key must remain retrievable under the
|
||||
// new casing (same entry), not deleted by a same-entry "migration".
|
||||
if key, ok, err := store.Get("Groq"); err != nil || !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key lost on case-only rename: key=%q ok=%v err=%v", key, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1", Description: "old text"},
|
||||
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://o.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-old"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{
|
||||
Name: "groq",
|
||||
NewName: "grok-main",
|
||||
Model: "m1-pro",
|
||||
Description: "", // cleared — must persist verbatim
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
edited := cfg.Providers[0]
|
||||
if edited.Name != "grok-main" || edited.Model != "m1-pro" || edited.Description != "" {
|
||||
t.Fatalf("edit not applied: %+v", edited)
|
||||
}
|
||||
if edited.BaseURL != "https://api.groq.com/openai/v1" || !edited.APIKeyStored {
|
||||
t.Fatalf("untouched fields must survive: %+v", edited)
|
||||
}
|
||||
if cfg.ActiveProvider != "grok-main" {
|
||||
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if key, ok, _ := store.Get("grok-main"); !ok || key != "sk-old" {
|
||||
t.Fatalf("stored key must migrate with the rename, got %q ok=%v", key, ok)
|
||||
}
|
||||
if len(cfg.Providers) != 2 || cfg.Providers[1].Name != "other" {
|
||||
t.Fatalf("unrelated profile changed: %+v", cfg.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped
|
||||
// RenameProvider on case-insensitively-equal names and fell into UpsertProvider,
|
||||
// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches
|
||||
// case-insensitively, so a case-only rename is an in-place update and the store
|
||||
// entry (case-normalized) survives.
|
||||
func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "groq",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if err := store.Set("groq", "sk-secret"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{Name: "groq", NewName: "Groq"})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("case-only rename must not duplicate the profile: %+v", cfg.Providers)
|
||||
}
|
||||
if cfg.Providers[0].Name != "Groq" || !cfg.Providers[0].APIKeyStored {
|
||||
t.Fatalf("in-place update wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.Providers[0].BaseURL != "https://api.groq.com/openai/v1" {
|
||||
t.Fatalf("fields must survive a case-only rename: %+v", cfg.Providers[0])
|
||||
}
|
||||
if cfg.ActiveProvider != "Groq" {
|
||||
t.Fatalf("active must follow, got %q", cfg.ActiveProvider)
|
||||
}
|
||||
if key, ok, _ := store.Get("Groq"); !ok || key != "sk-secret" {
|
||||
t.Fatalf("stored key lost on case-only rename: %q ok=%v", key, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditProviderRenameMigratesFreshlyCapturedKey: replacing the key AND
|
||||
// renaming in one edit — the caller captures under the CURRENT name and the
|
||||
// rename migration moves it, so the new key lands under the new name.
|
||||
func TestEditProviderRenameMigratesFreshlyCapturedKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
|
||||
path := filepath.Join(dir, "config.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "gw",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "gw", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gw.example.com/v1", APIKeyEnv: "GW_KEY", Model: "m1"},
|
||||
},
|
||||
}, 0o600)
|
||||
store, err := ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
// The caller's contract: a replacement key is stored under the CURRENT name
|
||||
// before EditProvider runs (what SecureProviderProfile does).
|
||||
if err := store.Set("gw", "sk-new"); err != nil {
|
||||
t.Fatalf("seed store: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := EditProvider(path, ProviderEdit{Name: "gw", NewName: "gateway", APIKeyStored: true})
|
||||
if err != nil {
|
||||
t.Fatalf("EditProvider() error = %v", err)
|
||||
}
|
||||
if !cfg.Providers[0].APIKeyStored || cfg.Providers[0].Name != "gateway" {
|
||||
t.Fatalf("marker/rename wrong: %+v", cfg.Providers[0])
|
||||
}
|
||||
if key, ok, _ := store.Get("gateway"); !ok || key != "sk-new" {
|
||||
t.Fatalf("captured key must migrate to the new name, got %q ok=%v", key, ok)
|
||||
}
|
||||
if _, ok, _ := store.Get("gw"); ok {
|
||||
t.Fatalf("old entry must be cleaned up after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
before := writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "alpha",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
|
||||
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
if _, err := EditProvider(path, ProviderEdit{Name: "alpha", NewName: "BETA"}); err == nil {
|
||||
t.Fatal("rename onto an existing name must fail")
|
||||
}
|
||||
if _, err := EditProvider(path, ProviderEdit{Name: "ghost", Model: "x"}); err == nil {
|
||||
t.Fatal("editing an unknown provider must fail")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read config: %v", err)
|
||||
}
|
||||
if string(after) != string(before) {
|
||||
t.Fatalf("config was rewritten by a rejected edit")
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
// Package dictation implements speech-to-text capture and transcription for
|
||||
// the composer: press a key, talk, and the transcript lands in the input for
|
||||
// review — never auto-fired at the agent.
|
||||
//
|
||||
// Two pipelines share a keybinding and a composer-insertion target:
|
||||
//
|
||||
// - Batch: record to a temp WAV file (Recorder.Start/Stop), then transcribe
|
||||
// in one shot (Transcriber.Transcribe) — a local sherpa-onnx-offline exec
|
||||
// or a single HTTPS POST to Groq/OpenAI. The only pipeline on Termux.
|
||||
// - Streaming: capture raw PCM from the mic tool's stdout
|
||||
// (Recorder.StartStreaming), feed 50ms chunks to a websocket
|
||||
// (Transcriber.StreamTranscribe), and render partial transcripts live.
|
||||
//
|
||||
// Every OS integration shells out via exec.Command with discrete argv — never
|
||||
// a shell string, never CGO — matching internal/imageinput/clipboard.go. The
|
||||
// external binaries (arecord/sox/ffmpeg/termux-microphone-record, sherpa-onnx)
|
||||
// are looked up on PATH and degrade to a clear setup message when missing,
|
||||
// matching internal/lsp's philosophy for language servers.
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Batch transcription providers (config value stt.provider).
|
||||
const (
|
||||
ProviderLocal = "local"
|
||||
ProviderGroq = "groq"
|
||||
ProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// Streaming transcription providers (config value stt.streamProvider).
|
||||
const (
|
||||
StreamProviderLocal = "local"
|
||||
StreamProviderDeepgram = "deepgram"
|
||||
StreamProviderOpenAI = "openai"
|
||||
)
|
||||
|
||||
// BatchProviders lists valid stt.provider values, for config validation.
|
||||
func BatchProviders() []string {
|
||||
return []string{ProviderLocal, ProviderGroq, ProviderOpenAI}
|
||||
}
|
||||
|
||||
// StreamProviders lists valid stt.streamProvider values, for config validation.
|
||||
func StreamProviders() []string {
|
||||
return []string{StreamProviderLocal, StreamProviderDeepgram, StreamProviderOpenAI}
|
||||
}
|
||||
|
||||
// AudioFormat identifies a recorded container format, sniffed from the bytes —
|
||||
// desktop recorders produce WAV, Termux's recorder produces M4A/AAC.
|
||||
type AudioFormat string
|
||||
|
||||
const (
|
||||
FormatWAV AudioFormat = "wav"
|
||||
FormatM4A AudioFormat = "m4a"
|
||||
FormatUnknown AudioFormat = ""
|
||||
)
|
||||
|
||||
// SniffFormat detects the audio container from magic bytes. WAV is
|
||||
// "RIFF....WAVE"; MP4-family (Termux's .m4a) has "ftyp" at offset 4.
|
||||
func SniffFormat(data []byte) AudioFormat {
|
||||
if len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WAVE")) {
|
||||
return FormatWAV
|
||||
}
|
||||
if len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp")) {
|
||||
return FormatM4A
|
||||
}
|
||||
return FormatUnknown
|
||||
}
|
||||
|
||||
// FileName returns a transcription-upload filename for the format.
|
||||
func (f AudioFormat) FileName() string {
|
||||
switch f {
|
||||
case FormatM4A:
|
||||
return "audio.m4a"
|
||||
default:
|
||||
return "audio.wav"
|
||||
}
|
||||
}
|
||||
|
||||
// SetupError reports a missing external tool with install guidance. Missing
|
||||
// binaries are an expected condition (the user hasn't set dictation up yet),
|
||||
// not a bug — callers show the hint instead of a raw exec error.
|
||||
type SetupError struct {
|
||||
Tool string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func (e *SetupError) Error() string {
|
||||
return fmt.Sprintf("%s not found — %s", e.Tool, e.Hint)
|
||||
}
|
||||
|
||||
// AuthError reports that a cloud provider rejected a transcription request for
|
||||
// authentication reasons (HTTP 401/403) — a missing or invalid API key. It is a
|
||||
// fixable credential problem, so callers (the TUI) can offer an inline key
|
||||
// prompt for Provider instead of a dead-end error. Message is the already-
|
||||
// classified, secret-redacted user-facing text.
|
||||
type AuthError struct {
|
||||
Provider string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string { return e.Message }
|
||||
@@ -1,893 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/bzip2"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auto-download of the local engine + a default model (opt-in, behind a confirm
|
||||
// in the F9 flow). Assets come from the official k2-fsa/sherpa-onnx GitHub
|
||||
// releases and are verified against a SHA256 digest before use — the same trust
|
||||
// boundary as installing them yourself, automated. Auto-downloading a native
|
||||
// executable is a real trust decision, which is why it is opt-in and verified.
|
||||
//
|
||||
// Rather than freeze a single hardcoded version, the downloader resolves the
|
||||
// release ASSETS via the GitHub API at download time and verifies each download
|
||||
// against the API's per-asset digest — so a newer sherpa-onnx release (or
|
||||
// "latest") works with no code change. A known-good version is still the DEFAULT
|
||||
// (reproducible, smoke-tested), and for that default the resolved digest is
|
||||
// cross-checked against a pinned value so a silently-changed release is caught.
|
||||
|
||||
// DefaultSherpaVersion is the pinned, smoke-tested release used unless
|
||||
// stt.engineVersion overrides it. "latest" or any tag also works.
|
||||
const DefaultSherpaVersion = "v1.13.3"
|
||||
|
||||
const (
|
||||
sherpaRepo = "k2-fsa/sherpa-onnx"
|
||||
modelReleaseTag = "asr-models" // rolling release the ASR models live in
|
||||
modelAssetName = "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2"
|
||||
defaultAPIBase = "https://api.github.com"
|
||||
)
|
||||
|
||||
// engineAssetSuffix maps GOOS-GOARCH to the version-independent suffix of the
|
||||
// sherpa-onnx executable bundle for that platform. Matching by suffix (not a
|
||||
// full version-embedded name) is what lets any release resolve. The bundles are
|
||||
// self-contained (RPATH $ORIGIN/../lib), so the extracted bin/ executables run
|
||||
// with no library-path setup.
|
||||
var engineAssetSuffix = map[string]string{
|
||||
"linux-amd64": "linux-x64-shared-no-tts.tar.bz2",
|
||||
"darwin-arm64": "osx-arm64-shared-no-tts.tar.bz2",
|
||||
"darwin-amd64": "osx-x64-shared-no-tts.tar.bz2",
|
||||
"windows-amd64": "win-x64-shared-MT-Release-no-tts.tar.bz2",
|
||||
}
|
||||
|
||||
// pinnedEngineDigest cross-checks the DefaultSherpaVersion engine assets (a
|
||||
// silently-changed pinned release is refused). Empty for non-default versions,
|
||||
// which are verified against the API digest only.
|
||||
var pinnedEngineDigest = map[string]string{
|
||||
"linux-amd64": "e5cbfd96f3666c25d2272d468e337764fce6a4d50ed074e4b8e5021bfd7fefc1",
|
||||
"darwin-arm64": "ea73233bc250019ccf1f8a16feb29460efcd3425b50cac16980bcb76281d60bf",
|
||||
"darwin-amd64": "563423887d63d8a831c473e0ab6815139fe2e7a3482936716c2a128071dd217f",
|
||||
"windows-amd64": "343c6c39cb4fe1880a9fb1abc8401d5174629ccbb147fca1fd53c440b2664570",
|
||||
}
|
||||
|
||||
// pinnedModelDigest cross-checks the default Moonshine-tiny int8 model.
|
||||
const pinnedModelDigest = "d5fe6ec4334fef36255b2a4010412cad4c007e33103fec62fb5d17cad88086f2"
|
||||
|
||||
// ModelVariant is a downloadable model the /stt-model picker offers, with a
|
||||
// pinned SHA256 (the model release predates GitHub's per-asset digests, so these
|
||||
// must be pinned). Batch variants use the offline binary; the streaming variant
|
||||
// (a transducer) drives the websocket server for a live transcript.
|
||||
type ModelVariant struct {
|
||||
ID string
|
||||
Label string
|
||||
Description string
|
||||
Bytes int64
|
||||
AssetName string
|
||||
Digest string
|
||||
DirName string
|
||||
Streaming bool
|
||||
// Recommended marks a curated, checksum-pinned, validated model (shown first).
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
// ModelVariants returns the curated English models the download picker offers.
|
||||
// This is a hand-picked shortlist (each pinned by SHA256 since the model release
|
||||
// predates GitHub's per-asset digests), NOT the whole sherpa-onnx model zoo —
|
||||
// many more models (other languages, sizes, and streaming/batch families) work
|
||||
// via a manual stt.localModelPath. Streaming (live) options come first.
|
||||
func ModelVariants() []ModelVariant {
|
||||
return []ModelVariant{
|
||||
// Live / streaming — transcript builds up as you speak.
|
||||
// DirName MUST match what ListModels derives from the asset ("model-"+id, id =
|
||||
// asset minus "sherpa-onnx-" prefix and ".tar.bz2" suffix, dates included) so a
|
||||
// model extracts to the same directory whether picked from the curated shortlist
|
||||
// or the full browse list — otherwise installed-detection disagrees between the
|
||||
// two views and the same model can be downloaded twice. Recommended matches the
|
||||
// full list too (recommendedModelIDs marks every curated variant), so the ★ and
|
||||
// ordering don't change when the background fetch swaps the curated list out.
|
||||
{
|
||||
ID: "streaming-zipformer-en-kroko", Label: "Kroko (newest, fastest)",
|
||||
Description: "smallest live model", Bytes: 54 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2",
|
||||
Digest: "c8676e5ff9ac2a85296e53ee0fd4d5fb1db6770e7a7647166eeafe349ade6834",
|
||||
DirName: "model-streaming-zipformer-en-kroko-2025-08-06",
|
||||
Streaming: true,
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
ID: "streaming-zipformer-20m", Label: "Zipformer 20M",
|
||||
Description: "small, well-tested", Bytes: 121 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-20M-2023-02-17.tar.bz2",
|
||||
Digest: "9c559283e8498d3fe95913c79ca1cb454bb26281ac2b102b41306c7d752765d9",
|
||||
DirName: "model-streaming-zipformer-en-20M-2023-02-17",
|
||||
Streaming: true,
|
||||
},
|
||||
{
|
||||
ID: "streaming-zipformer", Label: "Zipformer",
|
||||
Description: "larger, more accurate", Bytes: 297 << 20,
|
||||
AssetName: "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2",
|
||||
Digest: "639e25b578e9e997131402199419c13a941f8e4e198e2da1ce57dbf5cf401282",
|
||||
DirName: "model-streaming-zipformer-en-2023-06-26",
|
||||
Streaming: true,
|
||||
},
|
||||
{
|
||||
ID: "nemo-streaming-fast-conformer-transducer-en-80ms-int8", Label: "NeMo streaming (fast)",
|
||||
Description: "low-latency English", Bytes: 98 << 20,
|
||||
AssetName: "sherpa-onnx-nemo-streaming-fast-conformer-transducer-en-80ms-int8.tar.bz2",
|
||||
Digest: "7bd33a914e93370a1ba9c2066d9e841bdcad8613fa2a00537c1ae15d851a14d8",
|
||||
DirName: "model-nemo-streaming-fast-conformer-transducer-en-80ms-int8",
|
||||
Streaming: true,
|
||||
Recommended: true,
|
||||
},
|
||||
// Batch — transcribes once when you stop.
|
||||
{
|
||||
ID: "moonshine-tiny", Label: "Moonshine tiny",
|
||||
Description: "fast, smallest", Bytes: 103 << 20,
|
||||
AssetName: "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2",
|
||||
Digest: pinnedModelDigest,
|
||||
DirName: "model-moonshine-tiny-en-int8",
|
||||
},
|
||||
{
|
||||
ID: "moonshine-base", Label: "Moonshine base",
|
||||
Description: "more accurate, larger", Bytes: 239 << 20,
|
||||
AssetName: "sherpa-onnx-moonshine-base-en-int8.tar.bz2",
|
||||
Digest: "21870cecaa2e44e4e2bf63e02d1072bed183ccd10284871353bd9d24dad14e5e",
|
||||
DirName: "model-moonshine-base-en-int8",
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
ID: "sense-voice-zh-en-ja-ko-yue-int8-2025-09-09", Label: "SenseVoice",
|
||||
Description: "multilingual, accurate", Bytes: 158 << 20,
|
||||
AssetName: "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09.tar.bz2",
|
||||
Digest: "7305f7905bfcf77fa0b39388a313f3da35c68d971661a65475b56fb2162c8e63",
|
||||
DirName: "model-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09",
|
||||
Recommended: true,
|
||||
},
|
||||
{
|
||||
// Heavy (~1 GB) but the best transcription quality — recommended when
|
||||
// accuracy matters more than speed/size. The asr-models release publishes
|
||||
// no digest for it, so (like every browse-listed model) it downloads with
|
||||
// TLS-only verification.
|
||||
ID: "whisper-large-v3", Label: "Whisper large v3",
|
||||
Description: "best accuracy, heavy", Bytes: 1019 << 20,
|
||||
AssetName: "sherpa-onnx-whisper-large-v3.tar.bz2",
|
||||
DirName: "model-whisper-large-v3",
|
||||
Recommended: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// recommendedModelIDs marks the curated variants flagged Recommended so the full
|
||||
// browse list stars (and surfaces) exactly the ones the curated shortlist does —
|
||||
// keyed off the same Recommended flag so the two views never disagree.
|
||||
var recommendedModelIDs = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, v := range ModelVariants() {
|
||||
if v.Recommended {
|
||||
m[v.AssetName] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// pinnedModelDigests maps a model asset name to its pinned SHA256 (the curated
|
||||
// variants), so browse-listed models we happen to know get verified too.
|
||||
var pinnedModelDigests = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Digest
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// curatedLabels maps a curated model's asset name to its friendly label, so the
|
||||
// recommended rows read nicely in the full browse list too.
|
||||
var curatedLabels = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Label
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// browseLabel turns a raw asset id into a shorter display label: it drops the
|
||||
// redundant "-int8"/quantization noise and the "streaming-" prefix (the group
|
||||
// header already says live vs batch).
|
||||
func browseLabel(id string) string {
|
||||
label := strings.TrimPrefix(id, "streaming-")
|
||||
for _, drop := range []string{"-int8", ".int8", "-quantized", "-fp16"} {
|
||||
label = strings.ReplaceAll(label, drop, "")
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// ListModels fetches every transcription model in the sherpa-onnx asr-models
|
||||
// release and returns them as variants (the curated ones flagged Recommended and
|
||||
// carrying a pinned digest; the rest with an empty digest, downloaded with
|
||||
// TLS-only verification since that release predates GitHub's per-asset digests).
|
||||
// Non-ASR assets (VAD, TTS, punctuation, speaker/keyword/…): filtered out.
|
||||
func ListModels(ctx context.Context, client *http.Client, apiBase string) ([]ModelVariant, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBase
|
||||
}
|
||||
// Resolve the release id, then page its assets (a release can have hundreds).
|
||||
relURL := fmt.Sprintf("%s/repos/%s/releases/tags/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, modelReleaseTag)
|
||||
var rel struct {
|
||||
ID int64 `json:"id"`
|
||||
Assets []githubAsset
|
||||
}
|
||||
if err := getJSON(ctx, client, relURL, &rel); err != nil {
|
||||
return nil, fmt.Errorf("listing dictation models: %w", err)
|
||||
}
|
||||
assets := rel.Assets
|
||||
// If the inline assets look capped, page the assets endpoint for the rest.
|
||||
for page := 1; len(assets)%100 == 0 && len(assets) > 0; page++ {
|
||||
var more []githubAsset
|
||||
u := fmt.Sprintf("%s/repos/%s/releases/%d/assets?per_page=100&page=%d", strings.TrimRight(apiBase, "/"), sherpaRepo, rel.ID, page+1)
|
||||
if err := getJSON(ctx, client, u, &more); err != nil || len(more) == 0 {
|
||||
break
|
||||
}
|
||||
assets = append(assets, more...)
|
||||
if len(more) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []ModelVariant
|
||||
for _, a := range assets {
|
||||
if seen[a.Name] || !isTranscriptionModelAsset(a.Name) {
|
||||
continue
|
||||
}
|
||||
seen[a.Name] = true
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(a.Name, "sherpa-onnx-"), ".tar.bz2")
|
||||
digest := strings.TrimPrefix(a.Digest, "sha256:")
|
||||
if digest == "" {
|
||||
digest = pinnedModelDigests[a.Name] // may still be empty (browse/unverified)
|
||||
}
|
||||
label := browseLabel(id)
|
||||
desc := modelFamily(a.Name) // readable language, e.g. "French"
|
||||
if nice, ok := curatedLabels[a.Name]; ok {
|
||||
label = nice
|
||||
desc = curatedDescriptions[a.Name] // curated blurb ("fast, smallest")
|
||||
}
|
||||
out = append(out, ModelVariant{
|
||||
ID: id,
|
||||
Label: label,
|
||||
Description: desc,
|
||||
Bytes: a.Size,
|
||||
AssetName: a.Name,
|
||||
Digest: digest,
|
||||
DirName: "model-" + id,
|
||||
Streaming: strings.Contains(strings.ToLower(a.Name), "streaming-"),
|
||||
Recommended: recommendedModelIDs[a.Name],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, url string, v any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GitHub API HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// isTranscriptionModelAsset reports whether an asset is a speech-to-text model
|
||||
// (as opposed to a VAD/TTS/punctuation/speaker/keyword/enhancement tool).
|
||||
func isTranscriptionModelAsset(name string) bool {
|
||||
n := strings.ToLower(name)
|
||||
if !strings.HasPrefix(n, "sherpa-onnx-") || !strings.HasSuffix(n, ".tar.bz2") {
|
||||
return false
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"-vad", "vad-", "tts", "punct", "speaker", "diariz", "kws", "keyword",
|
||||
"audio-tagging", "denois", "source-separation", "speech-enhancement",
|
||||
"spoken-language", "language-identification", "-lid", "melo", "kokoro",
|
||||
"vits", "matcha", "piper", "wespeaker",
|
||||
// Rockchip NPU (RKNN) builds for specific ARM boards — not CPU/ONNX, so
|
||||
// the standard sherpa-onnx binary can't run them.
|
||||
"rknn", "rk3566", "rk3568", "rk3562", "rk3588", "rk3576", "rk3528", "rk3399",
|
||||
} {
|
||||
if strings.Contains(n, bad) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, fam := range []string{
|
||||
"zipformer", "moonshine", "whisper", "paraformer", "sense-voice",
|
||||
"sensevoice", "conformer", "nemo", "canary", "parakeet", "fire-red",
|
||||
"fireredasr", "dolphin", "telespeech", "wenet", "omnilingual", "funasr",
|
||||
"medasr", "cohere", "qwen", "citrinet", "transducer", "-ctc-", "zipvoice",
|
||||
} {
|
||||
if strings.Contains(n, fam) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// langNames maps ISO-ish language tokens in a model name to readable names.
|
||||
var langNames = map[string]string{
|
||||
"en": "English", "zh": "Chinese", "yue": "Cantonese", "fr": "French",
|
||||
"de": "German", "es": "Spanish", "ru": "Russian", "ja": "Japanese",
|
||||
"ko": "Korean", "bn": "Bengali", "ar": "Arabic", "hi": "Hindi",
|
||||
"pt": "Portuguese", "nl": "Dutch", "vi": "Vietnamese", "th": "Thai",
|
||||
"uk": "Ukrainian", "fa": "Persian", "pl": "Polish", "tr": "Turkish",
|
||||
"cs": "Czech", "sv": "Swedish", "fil": "Filipino", "tl": "Tagalog",
|
||||
"fi": "Finnish", "el": "Greek", "he": "Hebrew", "ro": "Romanian",
|
||||
}
|
||||
|
||||
// modelFamily is a short readable descriptor for a model, favoring the language
|
||||
// (the most useful thing not already obvious from the group header) and falling
|
||||
// back to the family. E.g. "French", "Chinese", "Multilingual".
|
||||
func modelFamily(name string) string {
|
||||
n := strings.ToLower(name)
|
||||
if strings.Contains(n, "multi") || strings.Contains(n, "bilingual") ||
|
||||
strings.Contains(n, "trilingual") || strings.Contains(n, "polyglot") ||
|
||||
strings.Contains(n, "omnilingual") {
|
||||
return "Multilingual"
|
||||
}
|
||||
var langs []string
|
||||
seen := map[string]bool{}
|
||||
add := func(name string) {
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
langs = append(langs, name)
|
||||
}
|
||||
}
|
||||
for _, tok := range strings.Split(strings.TrimSuffix(n, ".tar.bz2"), "-") {
|
||||
add(langNames[tok])
|
||||
}
|
||||
// Full language words spelled out in some names (e.g. "-cantonese-").
|
||||
for word, name := range map[string]string{
|
||||
"cantonese": "Cantonese", "mandarin": "Chinese", "english": "English",
|
||||
"chinese": "Chinese", "korean": "Korean", "japanese": "Japanese",
|
||||
"russian": "Russian", "arabic": "Arabic", "spanish": "Spanish",
|
||||
"french": "French", "german": "German", "vietnamese": "Vietnamese",
|
||||
"thai": "Thai", "farsi": "Persian", "persian": "Persian",
|
||||
"portuguese": "Portuguese", "italian": "Italian", "dutch": "Dutch",
|
||||
"ukrainian": "Ukrainian", "polish": "Polish", "turkish": "Turkish",
|
||||
} {
|
||||
if strings.Contains(n, word) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(langs) == 0:
|
||||
return ""
|
||||
case len(langs) > 2:
|
||||
return "Multilingual"
|
||||
default:
|
||||
return strings.Join(langs, " + ")
|
||||
}
|
||||
}
|
||||
|
||||
// curatedDescriptions maps a curated model's asset name to its quality blurb.
|
||||
var curatedDescriptions = func() map[string]string {
|
||||
m := map[string]string{}
|
||||
for _, v := range ModelVariants() {
|
||||
m[v.AssetName] = v.Description
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// EngineComponents identifies the resolved local-engine paths.
|
||||
type EngineComponents struct {
|
||||
BinaryPath string // extracted sherpa-onnx-offline
|
||||
ServerPath string // extracted sherpa-onnx-online-websocket-server
|
||||
ModelPath string // extracted model directory
|
||||
}
|
||||
|
||||
// DownloadOptions configures EnsureLocalEngine.
|
||||
type DownloadOptions struct {
|
||||
// DestRoot is where extracted engines/models live (e.g. ~/.config/zero/stt).
|
||||
DestRoot string
|
||||
// EngineVersion selects the sherpa-onnx release tag ("" → DefaultSherpaVersion;
|
||||
// "latest" and any tag also work).
|
||||
EngineVersion string
|
||||
// HTTPClient is injectable for tests; nil uses a plain client.
|
||||
HTTPClient *http.Client
|
||||
// APIBase overrides the GitHub API base (tests). "" → api.github.com.
|
||||
APIBase string
|
||||
// Model selection (default: the Moonshine-tiny int8 batch model). Set these
|
||||
// from a ModelVariant to download a different model.
|
||||
ModelAssetName string
|
||||
ModelPinnedDigest string
|
||||
ModelDirName string
|
||||
// ModelLabel is the friendly model name shown in progress ("Zipformer 20M").
|
||||
ModelLabel string
|
||||
// Progress receives live status strings for the UI, including a percentage
|
||||
// while downloading (e.g. "Engine 45% · 57/126 MB").
|
||||
Progress func(status string)
|
||||
// platformKey overrides runtime detection (tests).
|
||||
platformKey string
|
||||
// skipPinned disables the pinned-digest cross-check (tests, which use fixture
|
||||
// assets); the API-digest verification still runs.
|
||||
skipPinned bool
|
||||
}
|
||||
|
||||
// AutoDownloadSupported reports whether the current platform has a known engine
|
||||
// asset (so the F9 flow can offer the download). Termux/Android and unusual
|
||||
// arches install manually or use a cloud provider.
|
||||
func AutoDownloadSupported() bool {
|
||||
_, ok := engineAssetSuffix[platformKey()]
|
||||
return ok
|
||||
}
|
||||
|
||||
func platformKey() string { return runtime.GOOS + "-" + runtime.GOARCH }
|
||||
|
||||
// EnsureLocalEngine downloads (if absent) and verifies the sherpa-onnx engine and
|
||||
// the default model into DestRoot, returning the resolved paths. Idempotent: an
|
||||
// already-extracted, still-present engine/model is reused without re-downloading.
|
||||
// Every archive is SHA256-verified against the GitHub API digest before extraction.
|
||||
func EnsureLocalEngine(ctx context.Context, opts DownloadOptions) (EngineComponents, error) {
|
||||
if strings.TrimSpace(opts.DestRoot) == "" {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: DestRoot is required")
|
||||
}
|
||||
key := opts.platformKey
|
||||
if key == "" {
|
||||
key = platformKey()
|
||||
}
|
||||
suffix, ok := engineAssetSuffix[key]
|
||||
if !ok {
|
||||
return EngineComponents{}, &SetupError{
|
||||
Tool: "sherpa-onnx auto-download",
|
||||
Hint: fmt.Sprintf("no prebuilt engine is known for %s — install sherpa-onnx manually or use a cloud provider (see docs/dictation.md)", key),
|
||||
}
|
||||
}
|
||||
version := strings.TrimSpace(opts.EngineVersion)
|
||||
if version == "" {
|
||||
version = DefaultSherpaVersion
|
||||
}
|
||||
progress := opts.Progress
|
||||
if progress == nil {
|
||||
progress = func(string) {}
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
apiBase := opts.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAPIBase
|
||||
}
|
||||
|
||||
engineDir := filepath.Join(opts.DestRoot, "engine-"+version+"-"+key)
|
||||
targetWindows := strings.HasPrefix(key, "windows-")
|
||||
// Resolve through the tarball's flattened subdir so an ALREADY-extracted
|
||||
// engine is found and not needlessly re-downloaded (the idempotency check).
|
||||
binPath, serverPath := resolveEnginePaths(engineDir, targetWindows)
|
||||
if !fileExists(binPath) {
|
||||
pinned := ""
|
||||
if version == DefaultSherpaVersion && !opts.skipPinned {
|
||||
pinned = pinnedEngineDigest[key]
|
||||
}
|
||||
asset, err := resolveAsset(ctx, client, apiBase, version, "sherpa-onnx-", suffix)
|
||||
if err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
if err := downloadVerifyExtract(ctx, client, asset, pinned, false, "Engine", engineDir, progress); err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
binPath, serverPath = resolveEnginePaths(engineDir, targetWindows)
|
||||
}
|
||||
if !fileExists(binPath) {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: engine binary not found after extraction in %s", engineDir)
|
||||
}
|
||||
|
||||
modelName := opts.ModelAssetName
|
||||
if modelName == "" {
|
||||
modelName = modelAssetName
|
||||
}
|
||||
modelDirName := opts.ModelDirName
|
||||
if modelDirName == "" {
|
||||
modelDirName = "model-moonshine-tiny-en-int8"
|
||||
}
|
||||
modelDir := filepath.Join(opts.DestRoot, modelDirName)
|
||||
if !dirHasModel(modelDir) {
|
||||
asset, err := resolveAsset(ctx, client, apiBase, modelReleaseTag, modelName, "")
|
||||
if err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
modelPinned := opts.ModelPinnedDigest
|
||||
// Only fall back to the built-in digest for the DEFAULT model — a different
|
||||
// (browse-listed) model must never be checked against the default's digest.
|
||||
if modelPinned == "" && opts.ModelAssetName == "" {
|
||||
modelPinned = pinnedModelDigest
|
||||
}
|
||||
if opts.skipPinned {
|
||||
modelPinned = ""
|
||||
}
|
||||
modelLabel := opts.ModelLabel
|
||||
if modelLabel == "" {
|
||||
modelLabel = "Model"
|
||||
}
|
||||
// Models are data files from the official release: verify against a digest
|
||||
// when we have one (curated/pinned or API-provided), else allow a TLS-only
|
||||
// download (the model release predates GitHub's per-asset digests). The
|
||||
// engine BINARY above never allows this — a native executable is always
|
||||
// digest-verified.
|
||||
if err := downloadVerifyExtract(ctx, client, asset, modelPinned, true, modelLabel, modelDir, progress); err != nil {
|
||||
return EngineComponents{}, err
|
||||
}
|
||||
}
|
||||
resolvedModel := modelDir
|
||||
if !hasTokensFile(resolvedModel) {
|
||||
if child, err := flattenSingleChild(modelDir); err == nil {
|
||||
resolvedModel = child
|
||||
}
|
||||
}
|
||||
if !hasTokensFile(resolvedModel) {
|
||||
return EngineComponents{}, fmt.Errorf("dictation download: model files not found after extraction in %s", modelDir)
|
||||
}
|
||||
return EngineComponents{BinaryPath: binPath, ServerPath: serverPath, ModelPath: resolvedModel}, nil
|
||||
}
|
||||
|
||||
type resolvedAsset struct {
|
||||
url string
|
||||
sha256 string
|
||||
size int64
|
||||
name string
|
||||
}
|
||||
|
||||
// resolveAsset queries the GitHub release for tag and returns the asset whose
|
||||
// name has the given prefix and suffix, plus its SHA256 digest and size.
|
||||
func resolveAsset(ctx context.Context, client *http.Client, apiBase, tag, namePrefix, nameSuffix string) (resolvedAsset, error) {
|
||||
tagPath := "tags/" + tag
|
||||
if tag == "" || tag == "latest" {
|
||||
tagPath = "latest"
|
||||
}
|
||||
url := fmt.Sprintf("%s/repos/%s/releases/%s", strings.TrimRight(apiBase, "/"), sherpaRepo, tagPath)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return resolvedAsset{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: %w", tag, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resolvedAsset{}, fmt.Errorf("resolving sherpa-onnx release %s: GitHub API HTTP %d", tag, resp.StatusCode)
|
||||
}
|
||||
var release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return resolvedAsset{}, fmt.Errorf("parsing sherpa-onnx release %s: %w", tag, err)
|
||||
}
|
||||
for _, a := range release.Assets {
|
||||
if !strings.HasPrefix(a.Name, namePrefix) || !strings.HasSuffix(a.Name, nameSuffix) {
|
||||
continue
|
||||
}
|
||||
// The API digest may be empty for older assets (GitHub only populates it for
|
||||
// assets uploaded after ~2024 — the ASR models predate that). An empty
|
||||
// digest is fine here; downloadVerifyExtract requires the API digest OR a
|
||||
// pinned one and refuses only when neither exists.
|
||||
digest := strings.TrimPrefix(a.Digest, "sha256:")
|
||||
return resolvedAsset{url: a.DownloadURL, sha256: digest, size: a.Size, name: a.Name}, nil
|
||||
}
|
||||
return resolvedAsset{}, &SetupError{
|
||||
Tool: "sherpa-onnx auto-download",
|
||||
Hint: fmt.Sprintf("release %s has no asset matching %s*%s — try a different stt.engineVersion or install manually", tag, namePrefix, nameSuffix),
|
||||
}
|
||||
}
|
||||
|
||||
// downloadVerifyExtract fetches the asset to a temp file, verifies its SHA256
|
||||
// (against the API digest, and — when pinned is set — a cross-check that the
|
||||
// resolved digest equals the audited value), and extracts the tar.bz2. A
|
||||
// mismatch aborts before anything is extracted or run.
|
||||
func downloadVerifyExtract(ctx context.Context, client *http.Client, asset resolvedAsset, pinned string, allowUnverified bool, label, destDir string, progress func(string)) error {
|
||||
// Determine the digest to verify against: the API digest and the pinned digest
|
||||
// must agree when both exist; at least one must exist unless allowUnverified
|
||||
// (models are data files from the official release — TLS-only is acceptable;
|
||||
// a native executable never sets allowUnverified).
|
||||
expected := asset.sha256
|
||||
if pinned != "" {
|
||||
if expected != "" && !strings.EqualFold(expected, pinned) {
|
||||
return fmt.Errorf("dictation download: %s digest %s does not match the pinned known-good value %s — refusing (the release may have changed)", asset.name, asset.sha256, pinned)
|
||||
}
|
||||
expected = pinned
|
||||
}
|
||||
if expected == "" && !allowUnverified {
|
||||
return fmt.Errorf("dictation download: no SHA256 available for %s (neither the GitHub API nor a pinned value) — refusing an unverifiable download", asset.name)
|
||||
}
|
||||
// Ensure the parent of destDir exists; the final destDir is created by
|
||||
// the atomic rename at the end of the function.
|
||||
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Stage the extraction into a sibling temp dir and only swap it into
|
||||
// destDir on success. An interrupted run that left a truncated engine
|
||||
// binary in destDir would otherwise pass fileExists() on the next start
|
||||
// and break the local recorder silently.
|
||||
stageDir, err := os.MkdirTemp(filepath.Dir(destDir), filepath.Base(destDir)+".stage-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanupStage := true
|
||||
defer func() {
|
||||
if cleanupStage {
|
||||
_ = os.RemoveAll(stageDir)
|
||||
}
|
||||
}()
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(destDir), "stt-dl-*.tar.bz2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.url, nil)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: %w", asset.name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: HTTP %d", asset.name, resp.StatusCode)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
total := asset.size
|
||||
if total <= 0 {
|
||||
total = resp.ContentLength
|
||||
}
|
||||
src := io.Reader(resp.Body)
|
||||
if total > 0 {
|
||||
src = &progressReader{r: resp.Body, total: total, label: label, report: progress, lastPct: -1}
|
||||
}
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, hasher), src); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("downloading %s: %w", asset.name, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
got := hex.EncodeToString(hasher.Sum(nil))
|
||||
if expected != "" && !strings.EqualFold(got, expected) {
|
||||
return fmt.Errorf("dictation download: checksum mismatch for %s (want %s, got %s) — refusing to extract", asset.name, expected, got)
|
||||
}
|
||||
if err := extractTarBz2(tmpPath, stageDir, "Extracting "+label, progress); err != nil {
|
||||
return err
|
||||
}
|
||||
// Remove any pre-existing destDir so Rename is atomic on the same
|
||||
// filesystem (os.Rename refuses to overwrite a non-empty dir).
|
||||
if err := os.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("clearing previous %s install: %w", label, err)
|
||||
}
|
||||
if err := os.Rename(stageDir, destDir); err != nil {
|
||||
return fmt.Errorf("promoting staged %s: %w", label, err)
|
||||
}
|
||||
cleanupStage = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressReader reports download progress as a percentage on each whole-percent
|
||||
// change, formatted "<label> NN% · X/Y MB".
|
||||
type progressReader struct {
|
||||
r io.Reader
|
||||
total int64
|
||||
read int64
|
||||
label string
|
||||
report func(string)
|
||||
lastPct int
|
||||
}
|
||||
|
||||
func (p *progressReader) Read(b []byte) (int, error) {
|
||||
n, err := p.r.Read(b)
|
||||
p.read += int64(n)
|
||||
if p.total > 0 {
|
||||
pct := int(p.read * 100 / p.total)
|
||||
if pct != p.lastPct {
|
||||
p.lastPct = pct
|
||||
p.report(fmt.Sprintf("%s %d%% · %d/%d MB", p.label, pct, p.read>>20, p.total>>20))
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// enginePaths builds the engine executable paths. The ".exe" suffix follows the
|
||||
// TARGET platform (the one the engine was downloaded for), not the host — they
|
||||
// match in production (you download your own platform), but keying off the target
|
||||
// keeps resolution correct when the two differ (e.g. a cross-platform test).
|
||||
func enginePaths(engineDir string, targetWindows bool) (bin, server string) {
|
||||
ext := ""
|
||||
if targetWindows {
|
||||
ext = ".exe"
|
||||
}
|
||||
return filepath.Join(engineDir, "bin", "sherpa-onnx-offline"+ext),
|
||||
filepath.Join(engineDir, "bin", "sherpa-onnx-online-websocket-server"+ext)
|
||||
}
|
||||
|
||||
// resolveEnginePaths finds the executables, flattening the tarball's single
|
||||
// top-level directory when present.
|
||||
func resolveEnginePaths(engineDir string, targetWindows bool) (bin, server string) {
|
||||
bin, server = enginePaths(engineDir, targetWindows)
|
||||
if fileExists(bin) {
|
||||
return bin, server
|
||||
}
|
||||
if child, err := flattenSingleChild(engineDir); err == nil && child != engineDir {
|
||||
return enginePaths(child, targetWindows)
|
||||
}
|
||||
return bin, server
|
||||
}
|
||||
|
||||
// extractTarBz2 unpacks a bzip2-compressed tar into destDir, guarding against
|
||||
// path-traversal entries. It reports extraction progress by how much of the
|
||||
// (compressed) archive it has consumed — the same MB scale as the download, so
|
||||
// "Extracting … 50% · 520/1041 MB" lines up with the download that preceded it.
|
||||
func extractTarBz2(archivePath, destDir, label string, progress func(string)) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var src io.Reader = f
|
||||
if progress != nil {
|
||||
if info, statErr := f.Stat(); statErr == nil && info.Size() > 0 {
|
||||
src = &progressReader{r: f, total: info.Size(), label: label, report: progress, lastPct: -1}
|
||||
}
|
||||
}
|
||||
tr := tar.NewReader(bzip2.NewReader(src))
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(destDir, hdr.Name)
|
||||
absTarget, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if absTarget != destAbs && !strings.HasPrefix(absTarget, destAbs+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("dictation download: archive entry %q escapes destination", hdr.Name)
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)&0o777)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flattenSingleChild returns the sole subdirectory of dir (the tarball's
|
||||
// top-level folder) when dir contains exactly one directory entry.
|
||||
func flattenSingleChild(dir string) (string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var dirs []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
dirs = append(dirs, e.Name())
|
||||
}
|
||||
}
|
||||
if len(dirs) == 1 {
|
||||
return filepath.Join(dir, dirs[0]), nil
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// ModelDownloaded reports whether a model variant is already extracted under
|
||||
// destRoot (so the picker can mark it as installed). dirName is the variant's
|
||||
// DirName.
|
||||
func ModelDownloaded(destRoot, dirName string) bool {
|
||||
if destRoot == "" || dirName == "" {
|
||||
return false
|
||||
}
|
||||
return dirHasModel(filepath.Join(destRoot, dirName))
|
||||
}
|
||||
|
||||
// EngineDownloaded reports whether the shared sherpa-onnx engine is already on
|
||||
// disk for this platform (one engine serves every model), so the picker can drop
|
||||
// the engine's size from a model's download total once it's been fetched.
|
||||
func EngineDownloaded(destRoot, version string) bool {
|
||||
if destRoot == "" {
|
||||
return false
|
||||
}
|
||||
if version == "" {
|
||||
version = DefaultSherpaVersion
|
||||
}
|
||||
engineDir := filepath.Join(destRoot, "engine-"+version+"-"+platformKey())
|
||||
bin, _ := resolveEnginePaths(engineDir, runtime.GOOS == "windows")
|
||||
return fileExists(bin)
|
||||
}
|
||||
|
||||
func dirHasModel(dir string) bool {
|
||||
if hasTokensFile(dir) {
|
||||
return true
|
||||
}
|
||||
if child, err := flattenSingleChild(dir); err == nil && child != dir {
|
||||
return hasTokensFile(child)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasTokensFile reports whether dir holds a sherpa tokens file — usually
|
||||
// "tokens.txt", but some families (Whisper) prefix it as "<model>-tokens.txt".
|
||||
func hasTokensFile(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), "tokens.txt") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnsureLocalEngineRealDownload exercises the full auto-download against the
|
||||
// REAL k2-fsa/sherpa-onnx GitHub API (resolve → verify digest → extract → run).
|
||||
// It downloads ~130MB, so it is opt-in via ZERO_STT_DOWNLOAD_TEST=1.
|
||||
//
|
||||
// ZERO_STT_DOWNLOAD_TEST=1 go test ./internal/dictation/ -run RealDownload -v -timeout 10m
|
||||
func TestEnsureLocalEngineRealDownload(t *testing.T) {
|
||||
if os.Getenv("ZERO_STT_DOWNLOAD_TEST") == "" {
|
||||
t.Skip("set ZERO_STT_DOWNLOAD_TEST=1 to run the real ~130MB download smoke test")
|
||||
}
|
||||
dest := t.TempDir()
|
||||
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest,
|
||||
Progress: func(s string) { t.Logf("stage: %s", s) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureLocalEngine: %v", err)
|
||||
}
|
||||
if !fileExists(comp.BinaryPath) || !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
|
||||
t.Fatalf("missing components: %+v", comp)
|
||||
}
|
||||
|
||||
// The downloaded engine + model must actually transcribe.
|
||||
wav := filepath.Join(comp.ModelPath, "test_wavs", "0.wav")
|
||||
if !fileExists(wav) {
|
||||
t.Skipf("no bundled test wav at %s", wav)
|
||||
}
|
||||
audio, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr, err := NewLocalTranscriber(LocalConfig{Binary: comp.BinaryPath, ModelPath: comp.ModelPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := tr.Transcribe(context.Background(), audio)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe with downloaded engine: %v", err)
|
||||
}
|
||||
if text == "" {
|
||||
t.Fatal("downloaded engine returned empty transcript")
|
||||
}
|
||||
t.Logf("downloaded-engine transcript: %q", text)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tiny tar.bz2 fixtures (generated in-repo): the engine has top/bin/sherpa-onnx-
|
||||
// offline + server; the model has top/tokens.txt.
|
||||
const (
|
||||
engineTarB64 = "QlpoOTFBWSZTWW+fEfAAAW1/hMGRAIBAA/+AAgAgRH9v3+AAAQCoMAEYAYA00NGjEZANABoYYA00NGjEZANABoYIpE0TRoTGkA2poGgxqepl1H6w5anW3tY2OggUi1AW/d29pO0GLUoOtnQsrGFNowIEh0cImtQ9gyHqYH7unm+/wZSLXsBuBDDCfHiahCsGEgGL9/pUG+RQZo76xlaDIGKWDErPKuyhXYOlzJMG3iNa56kiluctzUqgZ2A1AGwgcB3jtB5PB7pYYlDHH8OvOznM6B6MHPRqa7sTE1vwDBc0LjmG4ah5F+YYaXHTZpGmcemWW1yMh1adhdrB6A3nAQP8XckU4UJBvnxHwA=="
|
||||
engineSHA = "97b5cb6a417705860f8940e1bf9ffec8cc60e8666a0d228cd60f1de7607d503d"
|
||||
modelTarB64 = "QlpoOTFBWSZTWTtAoX4AANp/hMOQBIBAAf+AAAIQBHoJ3mAAAQAIMAC5sG1SNMhNqGmQwIyeKEagam1DQ0AAABFRT0T0mg0aeoDQHpH7Nzrs24wsbVW8hMQHClNI0LuLhKMGY4sEQi6N2qQWP1id8o1KtprGd4hll4uQkMGA1nFMXAwohEZOnIMIPQKQl2k0GbSGwkC0lJb2IWOQ57ETYbmxFGspaRp1QyT6q4sKj855st7GzOGj5eIwmWt6wRajEQl/F3JFOFCQO0Chfg=="
|
||||
modelSHA = "16c6b1dcb40f6b21c0de70bc1a8edab95375e4de90dac7c076fd2cc7a34fc59a"
|
||||
)
|
||||
|
||||
// fakeReleaseServer serves the GitHub release API + asset bytes for the engine
|
||||
// and model, exercising resolveAsset + downloadVerifyExtract against a real HTTP
|
||||
// round-trip (with the real coder/websocket-free download path).
|
||||
func fakeReleaseServer(t *testing.T, engineDigest, modelDigest string) *httptest.Server {
|
||||
t.Helper()
|
||||
engineBytes, _ := base64.StdEncoding.DecodeString(engineTarB64)
|
||||
modelBytes, _ := base64.StdEncoding.DecodeString(modelTarB64)
|
||||
mux := http.NewServeMux()
|
||||
base := ""
|
||||
release := func(assetName, digest, dlPath string) string {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tag_name": "test",
|
||||
"assets": []map[string]any{{
|
||||
"name": assetName,
|
||||
"browser_download_url": base + dlPath,
|
||||
"digest": "sha256:" + digest,
|
||||
"size": 123,
|
||||
}},
|
||||
})
|
||||
return string(body)
|
||||
}
|
||||
var srv *httptest.Server
|
||||
// Any release tag under this prefix resolves: asr-models → the model, every
|
||||
// other tag → the engine (so the default-version pinned-check test works too).
|
||||
mux.HandleFunc("/repos/k2-fsa/sherpa-onnx/releases/tags/", func(w http.ResponseWriter, r *http.Request) {
|
||||
base = srv.URL
|
||||
if strings.HasSuffix(r.URL.Path, "/asr-models") {
|
||||
fmt.Fprint(w, release(modelAssetName, modelDigest, "/model.tar.bz2"))
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, release("sherpa-onnx-test-linux-x64-shared-no-tts.tar.bz2", engineDigest, "/engine.tar.bz2"))
|
||||
})
|
||||
mux.HandleFunc("/engine.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(engineBytes) })
|
||||
mux.HandleFunc("/model.tar.bz2", func(w http.ResponseWriter, r *http.Request) { w.Write(modelBytes) })
|
||||
srv = httptest.NewServer(mux)
|
||||
base = srv.URL
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestResolveEnginePathsHonorsTargetPlatform(t *testing.T) {
|
||||
// The engine binary name follows the TARGET platform, not the host: a Windows
|
||||
// engine has ".exe", a Linux one doesn't. Resolution must find each regardless
|
||||
// of the OS the test runs on (this is what makes the cross-platform download
|
||||
// test pass on Windows CI).
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target bool
|
||||
binName string
|
||||
}{
|
||||
{"linux target", false, "sherpa-onnx-offline"},
|
||||
{"windows target", true, "sherpa-onnx-offline.exe"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
binDir := filepath.Join(dir, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(binDir, tc.binName), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, _ := resolveEnginePaths(dir, tc.target)
|
||||
if !fileExists(bin) {
|
||||
t.Errorf("resolveEnginePaths(target-windows=%v) = %q; not found", tc.target, bin)
|
||||
}
|
||||
if !strings.HasSuffix(bin, tc.binName) {
|
||||
t.Errorf("bin = %q, want suffix %q", bin, tc.binName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEngineDownloadsAndExtracts(t *testing.T) {
|
||||
srv := fakeReleaseServer(t, engineSHA, modelSHA)
|
||||
dest := t.TempDir()
|
||||
var stages []string
|
||||
comp, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest,
|
||||
EngineVersion: "test",
|
||||
APIBase: srv.URL,
|
||||
platformKey: "linux-amd64",
|
||||
skipPinned: true,
|
||||
Progress: func(s string) { stages = append(stages, s) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureLocalEngine: %v", err)
|
||||
}
|
||||
if !fileExists(comp.BinaryPath) || !strings.HasSuffix(comp.BinaryPath, "sherpa-onnx-offline") {
|
||||
t.Errorf("binary path wrong: %q", comp.BinaryPath)
|
||||
}
|
||||
if !fileExists(comp.ServerPath) {
|
||||
t.Errorf("server path missing: %q", comp.ServerPath)
|
||||
}
|
||||
if !fileExists(filepath.Join(comp.ModelPath, "tokens.txt")) {
|
||||
t.Errorf("model tokens.txt missing under %q", comp.ModelPath)
|
||||
}
|
||||
// The extracted binary keeps its exec bit — Unix only; Windows has no
|
||||
// executable permission bit, so os.FileMode never reports 0o100 there.
|
||||
if runtime.GOOS != "windows" {
|
||||
if info, err := os.Stat(comp.BinaryPath); err == nil && info.Mode()&0o100 == 0 {
|
||||
t.Error("engine binary should be executable")
|
||||
}
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
t.Error("expected progress stages")
|
||||
}
|
||||
|
||||
// Idempotent: a second call reuses the extracted engine + model and downloads
|
||||
// NOTHING (no progress stages) — the fix for re-downloading an already-present
|
||||
// engine whose binary lives in the tarball's flattened subdir.
|
||||
var stages2 []string
|
||||
comp2, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: dest, EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
Progress: func(s string) { stages2 = append(stages2, s) },
|
||||
})
|
||||
if err != nil || comp2.BinaryPath != comp.BinaryPath {
|
||||
t.Errorf("second call should reuse: %v / %q vs %q", err, comp2.BinaryPath, comp.BinaryPath)
|
||||
}
|
||||
if len(stages2) != 0 {
|
||||
t.Errorf("a fully-cached second call must not download/extract anything, got stages: %v", stages2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEngineRejectsChecksumMismatch(t *testing.T) {
|
||||
// Serve the engine with a wrong digest → verification must refuse to extract.
|
||||
srv := fakeReleaseServer(t, strings.Repeat("0", 64), modelSHA)
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected checksum-mismatch error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLocalEnginePinnedCrossCheckRefusesChangedRelease(t *testing.T) {
|
||||
// Default version + a digest that doesn't match the pinned value → refuse,
|
||||
// even though the download itself would verify against the API digest.
|
||||
srv := fakeReleaseServer(t, engineSHA, modelSHA)
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(),
|
||||
EngineVersion: DefaultSherpaVersion, // triggers the pinned cross-check
|
||||
APIBase: srv.URL,
|
||||
platformKey: "linux-amd64",
|
||||
// skipPinned false: the pinned digest for linux-amd64 != engineSHA fixture.
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "pinned known-good") {
|
||||
t.Fatalf("expected pinned cross-check refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAssetMissingDigestAllowed(t *testing.T) {
|
||||
// An older asset with no API digest resolves fine (verification against a
|
||||
// pinned value happens later); resolveAsset no longer refuses here.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"assets": []map[string]any{{"name": "x-linux-x64-shared-no-tts.tar.bz2", "browser_download_url": "http://x", "digest": ""}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
asset, err := resolveAsset(context.Background(), http.DefaultClient, srv.URL, "test", "x-", "linux-x64-shared-no-tts.tar.bz2")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAsset should tolerate a missing digest, got %v", err)
|
||||
}
|
||||
if asset.sha256 != "" {
|
||||
t.Errorf("expected empty digest, got %q", asset.sha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnverifiableDownloadRefused(t *testing.T) {
|
||||
// No API digest AND no pinned digest → refuse to download/extract.
|
||||
srv := fakeReleaseServer(t, "", "") // both assets served with empty digest
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{
|
||||
DestRoot: t.TempDir(), EngineVersion: "test", APIBase: srv.URL, platformKey: "linux-amd64", skipPinned: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "unverifiable") {
|
||||
t.Fatalf("expected refusal of an unverifiable download, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressReaderReportsPercent(t *testing.T) {
|
||||
data := make([]byte, 1000)
|
||||
var last string
|
||||
pr := &progressReader{r: bytesReader(data), total: 1000, label: "Engine", report: func(s string) { last = s }, lastPct: -1}
|
||||
buf := make([]byte, 100)
|
||||
for {
|
||||
if _, err := pr.Read(buf); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(last, "Engine") || !strings.Contains(last, "100%") {
|
||||
t.Errorf("expected a final 'Engine 100%%' report, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesReader(b []byte) *sliceReader { return &sliceReader{b: b} }
|
||||
|
||||
type sliceReader struct {
|
||||
b []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (s *sliceReader) Read(p []byte) (int, error) {
|
||||
if s.pos >= len(s.b) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, s.b[s.pos:])
|
||||
s.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestUnsupportedPlatformSetupError(t *testing.T) {
|
||||
_, err := EnsureLocalEngine(context.Background(), DownloadOptions{DestRoot: t.TempDir(), platformKey: "plan9-mips"})
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for unsupported platform, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListModelsFiltersAndFlags(t *testing.T) {
|
||||
assets := []map[string]any{
|
||||
{"name": "sherpa-onnx-streaming-zipformer-en-2023-06-26.tar.bz2", "size": 1 << 20, "digest": "sha256:abc"},
|
||||
{"name": "sherpa-onnx-whisper-tiny.en.tar.bz2", "size": 2 << 20},
|
||||
{"name": "sherpa-onnx-moonshine-tiny-en-int8.tar.bz2", "size": 3 << 20}, // curated but not flagged recommended
|
||||
{"name": "sherpa-onnx-streaming-zipformer-en-kroko-2025-08-06.tar.bz2", "size": 1 << 20}, // curated → recommended
|
||||
{"name": "sherpa-onnx-paraformer-zh-2024-03-09.tar.bz2", "size": 4 << 20},
|
||||
// Non-ASR tools that MUST be filtered out:
|
||||
{"name": "sherpa-onnx-vad-silero.tar.bz2", "size": 1},
|
||||
{"name": "vits-piper-en_US-amy.tar.bz2", "size": 1},
|
||||
{"name": "sherpa-onnx-punct-en.tar.bz2", "size": 1},
|
||||
{"name": "sherpa-onnx-kws-zipformer.tar.bz2", "size": 1},
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 1, "assets": assets})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
vs, err := ListModels(context.Background(), http.DefaultClient, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vs) != 5 {
|
||||
t.Fatalf("expected 5 transcription models (tools filtered), got %d", len(vs))
|
||||
}
|
||||
byID := map[string]ModelVariant{}
|
||||
for _, v := range vs {
|
||||
byID[v.ID] = v
|
||||
}
|
||||
if !byID["streaming-zipformer-en-2023-06-26"].Streaming {
|
||||
t.Error("streaming model should be flagged Streaming")
|
||||
}
|
||||
if byID["whisper-tiny.en"].Streaming {
|
||||
t.Error("whisper is batch, not streaming")
|
||||
}
|
||||
if !byID["streaming-zipformer-en-kroko-2025-08-06"].Recommended {
|
||||
t.Error("a curated model flagged Recommended should be Recommended")
|
||||
}
|
||||
if byID["moonshine-tiny-en-int8"].Recommended {
|
||||
t.Error("moonshine tiny is curated but no longer flagged Recommended (no star)")
|
||||
}
|
||||
if byID["paraformer-zh-2024-03-09"].Recommended {
|
||||
t.Error("an uncurated model should not be Recommended")
|
||||
}
|
||||
// A curated model with no API digest still gets its pinned digest.
|
||||
if byID["moonshine-tiny-en-int8"].Digest == "" {
|
||||
t.Error("curated model should carry a pinned digest")
|
||||
}
|
||||
// An uncurated model with no API digest has an empty digest (TLS-only download).
|
||||
if byID["whisper-tiny.en"].Digest != "" {
|
||||
t.Error("uncurated model without an API digest should have an empty digest")
|
||||
}
|
||||
// The API-provided digest is used when present.
|
||||
if byID["streaming-zipformer-en-2023-06-26"].Digest != "abc" {
|
||||
t.Errorf("expected the API digest, got %q", byID["streaming-zipformer-en-2023-06-26"].Digest)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLocalTranscribeRealBinary exercises the full local batch path against a
|
||||
// REAL sherpa-onnx-offline binary and model — the manual smoke test §14 calls
|
||||
// out as not CI-able. Skipped unless both env vars point at a real install:
|
||||
//
|
||||
// ZERO_STT_TEST_BINARY=/path/to/sherpa-onnx-offline \
|
||||
// ZERO_STT_TEST_MODEL=/path/to/model-dir \
|
||||
// ZERO_STT_TEST_WAV=/path/to/16k-mono.wav \
|
||||
// go test ./internal/dictation/ -run TestLocalTranscribeRealBinary -v
|
||||
func TestLocalTranscribeRealBinary(t *testing.T) {
|
||||
binary := os.Getenv("ZERO_STT_TEST_BINARY")
|
||||
model := os.Getenv("ZERO_STT_TEST_MODEL")
|
||||
wav := os.Getenv("ZERO_STT_TEST_WAV")
|
||||
if binary == "" || model == "" || wav == "" {
|
||||
t.Skip("set ZERO_STT_TEST_BINARY, ZERO_STT_TEST_MODEL, ZERO_STT_TEST_WAV to run the real-engine smoke test")
|
||||
}
|
||||
audio, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatalf("read wav: %v", err)
|
||||
}
|
||||
tr, err := NewLocalTranscriber(LocalConfig{Binary: binary, ModelPath: model})
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocalTranscriber: %v", err)
|
||||
}
|
||||
text, err := tr.Transcribe(context.Background(), audio)
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if text == "" {
|
||||
t.Fatal("real engine returned an empty transcript")
|
||||
}
|
||||
t.Logf("transcript: %q", text)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// checkAudioDirOwner rejects a dictation temp directory not owned by the
|
||||
// current user — on a shared /tmp another user could have pre-created the
|
||||
// path and would then control its lifetime. Mirrors
|
||||
// internal/tools/spill_owner_unix.go.
|
||||
func checkAudioDirOwner(info os.FileInfo) error {
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if int(stat.Uid) != os.Geteuid() {
|
||||
return fmt.Errorf("dictation temp directory is owned by uid %d, not the current user", stat.Uid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package dictation
|
||||
|
||||
import "os"
|
||||
|
||||
// checkAudioDirOwner is a no-op on Windows: %TEMP% is per-user by default and
|
||||
// there is no portable uid to compare. The Lstat symlink/dir check in
|
||||
// audioSpillDir still applies. Mirrors internal/tools/spill_owner_windows.go.
|
||||
func checkAudioDirOwner(os.FileInfo) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Recorder captures microphone audio. Start/Stop is the batch path (used on
|
||||
// Termux, and as the fallback everywhere); StartStreaming is the desktop-only
|
||||
// continuous-PCM path.
|
||||
type Recorder interface {
|
||||
Start() error
|
||||
// Stop ends a batch recording and returns the recorded audio bytes (WAV on
|
||||
// desktop, M4A on Termux — SniffFormat distinguishes them).
|
||||
Stop() ([]byte, error)
|
||||
// StartStreaming begins continuous capture of raw little-endian PCM16 mono
|
||||
// at the configured sample rate. Chunks arrive on the channel in ~50ms
|
||||
// units; the channel closes when capture ends (stop called, max duration
|
||||
// hit, or trailing-silence auto-stop). stop is idempotent.
|
||||
StartStreaming() (chunks <-chan []byte, stop func() error, err error)
|
||||
}
|
||||
|
||||
// Platform selects the audio-capture toolchain (§9 of the design doc).
|
||||
type Platform string
|
||||
|
||||
const (
|
||||
PlatformLinux Platform = "linux"
|
||||
PlatformDarwin Platform = "darwin"
|
||||
PlatformWindows Platform = "windows"
|
||||
PlatformTermux Platform = "termux"
|
||||
PlatformUnsupported Platform = ""
|
||||
)
|
||||
|
||||
// DetectPlatform picks the capture toolchain for the current host. Termux is
|
||||
// detected by its environment (it reports GOOS=linux/android): TERMUX_VERSION
|
||||
// is exported to every Termux shell, and PREFIX carries the app package path.
|
||||
func DetectPlatform() Platform {
|
||||
if os.Getenv("TERMUX_VERSION") != "" || strings.Contains(os.Getenv("PREFIX"), "com.termux") {
|
||||
return PlatformTermux
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return PlatformLinux
|
||||
case "darwin":
|
||||
return PlatformDarwin
|
||||
case "windows":
|
||||
return PlatformWindows
|
||||
}
|
||||
return PlatformUnsupported
|
||||
}
|
||||
|
||||
// StreamingSupported reports whether the platform's capture tool can write raw
|
||||
// PCM to stdout. Termux's termux-microphone-record requires a regular output
|
||||
// file — a hard tool limitation, so Termux is batch-only permanently.
|
||||
func (p Platform) StreamingSupported() bool {
|
||||
switch p {
|
||||
case PlatformLinux, PlatformDarwin, PlatformWindows:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultSampleRate is what the local engine and Deepgram consume.
|
||||
// OpenAI Realtime's pcm16 requires 24kHz; its transcriber constructs the
|
||||
// recorder accordingly.
|
||||
DefaultSampleRate = 16000
|
||||
// DefaultMaxDuration is the hard runaway-recording cap (§12), applied on
|
||||
// every platform, batch and streaming. Overridable via
|
||||
// stt.maxDurationSeconds.
|
||||
DefaultMaxDuration = 5 * time.Minute
|
||||
|
||||
// Trailing-silence auto-stop (§9): stop ~2s after the signal falls below a
|
||||
// 3% amplitude threshold — SoX's own `silence 1 0.1 3% 1 2.0 3%` numbers.
|
||||
silenceThresholdPct = 3
|
||||
silenceHold = 2 * time.Second
|
||||
|
||||
chunkInterval = 50 * time.Millisecond
|
||||
stopGrace = 3 * time.Second
|
||||
)
|
||||
|
||||
// RecorderOptions configures a Recorder. Zero values pick sensible defaults.
|
||||
type RecorderOptions struct {
|
||||
Platform Platform
|
||||
SampleRate int
|
||||
MaxDuration time.Duration
|
||||
SilenceAutoStop bool
|
||||
// WindowsAudioDevice is the dshow capture device name; auto-detected from
|
||||
// `ffmpeg -list_devices` when empty.
|
||||
WindowsAudioDevice string
|
||||
|
||||
// Test seams (unexported): fake the OS boundary, mirroring how
|
||||
// internal/lsp/manager_test.go stubs serverStarter.
|
||||
starter processStarter
|
||||
runOutput commandOutputRunner
|
||||
tempDir func() (string, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (o RecorderOptions) withDefaults() RecorderOptions {
|
||||
if o.Platform == PlatformUnsupported {
|
||||
o.Platform = DetectPlatform()
|
||||
}
|
||||
if o.SampleRate <= 0 {
|
||||
o.SampleRate = DefaultSampleRate
|
||||
}
|
||||
if o.MaxDuration <= 0 {
|
||||
o.MaxDuration = DefaultMaxDuration
|
||||
}
|
||||
if o.starter == nil {
|
||||
o.starter = startProcess
|
||||
}
|
||||
if o.runOutput == nil {
|
||||
o.runOutput = runCommandOutput
|
||||
}
|
||||
if o.tempDir == nil {
|
||||
o.tempDir = audioSpillDir
|
||||
}
|
||||
if o.now == nil {
|
||||
o.now = time.Now
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// NewRecorder builds the platform recorder. Construction is cheap and
|
||||
// side-effect free; tools are only exec'd on Start/StartStreaming.
|
||||
func NewRecorder(opts RecorderOptions) Recorder {
|
||||
o := opts.withDefaults()
|
||||
return &recorder{opts: o}
|
||||
}
|
||||
|
||||
type recorder struct {
|
||||
opts RecorderOptions
|
||||
|
||||
mu sync.Mutex
|
||||
proc processHandle
|
||||
filePath string
|
||||
recording bool
|
||||
}
|
||||
|
||||
var errAlreadyRecording = errors.New("a recording is already in progress")
|
||||
|
||||
// Start begins a batch (record-to-file) capture.
|
||||
func (r *recorder) Start() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.recording {
|
||||
return errAlreadyRecording
|
||||
}
|
||||
if r.opts.Platform == PlatformUnsupported {
|
||||
return fmt.Errorf("dictation is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
dir, err := r.opts.tempDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("preparing dictation temp dir: %w", err)
|
||||
}
|
||||
ext := ".wav"
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
ext = ".m4a"
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "rec-*"+ext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating dictation temp file: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
// termux-microphone-record starts the recording in the Termux:API
|
||||
// companion app and exits immediately; there is no long-lived process
|
||||
// to hold. Stop is a separate `-q` invocation.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, err := r.opts.runOutput(ctx, "termux-microphone-record",
|
||||
"-l", strconv.Itoa(maxSeconds(r.opts.MaxDuration)), "-f", path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return startError("termux-microphone-record", r.opts.Platform, err, out)
|
||||
}
|
||||
r.filePath = path
|
||||
r.recording = true
|
||||
return nil
|
||||
}
|
||||
|
||||
spec, err := batchCommand(r.opts, path)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
proc, _, err := r.opts.starter(spec)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return startError(spec.name, r.opts.Platform, err, nil)
|
||||
}
|
||||
r.proc = proc
|
||||
r.filePath = path
|
||||
r.recording = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop ends a batch capture, reads the recorded file, deletes it, and returns
|
||||
// the audio bytes.
|
||||
func (r *recorder) Stop() ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.recording {
|
||||
return nil, errors.New("no recording in progress")
|
||||
}
|
||||
proc, path := r.proc, r.filePath
|
||||
// Termux: Start's termux-microphone-record branch leaves r.proc nil because
|
||||
// there's no long-lived process to hold (Stop is a separate `-q` invocation
|
||||
// via runOutput). Treat that as legitimate; only reject a nil proc that
|
||||
// came from StartStreaming (which owns its process via the returned
|
||||
// closure).
|
||||
if proc == nil && r.opts.Platform != PlatformTermux {
|
||||
return nil, errors.New("recording was started with StartStreaming; call the stop closure returned from StartStreaming instead of Stop()")
|
||||
}
|
||||
r.proc, r.filePath, r.recording = nil, "", false
|
||||
defer os.Remove(path)
|
||||
|
||||
if r.opts.Platform == PlatformTermux {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if out, err := r.opts.runOutput(ctx, "termux-microphone-record", "-q"); err != nil {
|
||||
return nil, fmt.Errorf("stopping termux recording: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return readRecordingWithRetry(path, r.opts.now)
|
||||
}
|
||||
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
// A signal-terminated capture tool reports a non-zero exit — expected, not
|
||||
// an error. The recorded file's contents are the real success signal.
|
||||
_ = waitWithTimeout(proc, stopGrace)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading recorded audio: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("no audio was captured (is the microphone available?)")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readRecordingWithRetry reads the Termux recording, retrying briefly: the
|
||||
// companion app finalizes the file asynchronously after `-q` returns.
|
||||
func readRecordingWithRetry(path string, now func() time.Time) ([]byte, error) {
|
||||
deadline := now().Add(2 * time.Second)
|
||||
for {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
if now().After(deadline) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading recorded audio: %w", err)
|
||||
}
|
||||
return nil, errors.New("no audio was captured (check Termux:API app and microphone permission)")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// batchCommand builds the record-to-file argv for desktop platforms (§9).
|
||||
// The max-duration cap is enforced by the tool itself so a runaway recording
|
||||
// ends even if Zero never calls Stop.
|
||||
func batchCommand(opts RecorderOptions, path string) (commandSpec, error) {
|
||||
rate := strconv.Itoa(opts.SampleRate)
|
||||
maxSec := strconv.Itoa(maxSeconds(opts.MaxDuration))
|
||||
switch opts.Platform {
|
||||
case PlatformLinux:
|
||||
return commandSpec{
|
||||
name: "arecord",
|
||||
args: []string{"-q", "-f", "S16_LE", "-r", rate, "-c", "1", "-t", "wav", "-d", maxSec, path},
|
||||
}, nil
|
||||
case PlatformDarwin:
|
||||
args := []string{"-q", "-d", "-r", rate, "-c", "1", "-b", "16", "-e", "signed-integer", path, "trim", "0", maxSec}
|
||||
if opts.SilenceAutoStop {
|
||||
// SoX's built-in trailing-silence auto-stop: end ~2s after the
|
||||
// signal falls below 3% amplitude. A backstop for a forgotten
|
||||
// manual stop, not VAD — recording still starts on an explicit key.
|
||||
args = append(args, "silence", "1", "0.1", "3%", "1", "2.0", "3%")
|
||||
}
|
||||
return commandSpec{name: "sox", args: args}, nil
|
||||
case PlatformWindows:
|
||||
device := opts.WindowsAudioDevice
|
||||
if device == "" {
|
||||
detected, err := detectWindowsAudioDevice(opts.runOutput)
|
||||
if err != nil {
|
||||
return commandSpec{}, err
|
||||
}
|
||||
device = detected
|
||||
}
|
||||
return commandSpec{
|
||||
name: "ffmpeg",
|
||||
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
|
||||
"-ac", "1", "-ar", rate, "-c:a", "pcm_s16le", "-t", maxSec, "-y", path},
|
||||
stopViaStdin: "q",
|
||||
}, nil
|
||||
}
|
||||
return commandSpec{}, fmt.Errorf("no batch capture tool for platform %q", opts.Platform)
|
||||
}
|
||||
|
||||
// streamCommand builds the raw-PCM-to-stdout argv for desktop platforms (§9).
|
||||
// Same tools as batch, invoked with their stdout flag — Termux has no stdout
|
||||
// mode, which is why it is batch-only.
|
||||
func streamCommand(opts RecorderOptions) (commandSpec, error) {
|
||||
rate := strconv.Itoa(opts.SampleRate)
|
||||
switch opts.Platform {
|
||||
case PlatformLinux:
|
||||
return commandSpec{
|
||||
name: "arecord",
|
||||
args: []string{"-q", "-t", "raw", "-f", "S16_LE", "-r", rate, "-c", "1", "-"},
|
||||
wantStdout: true,
|
||||
}, nil
|
||||
case PlatformDarwin:
|
||||
return commandSpec{
|
||||
name: "sox",
|
||||
args: []string{"-q", "-d", "-t", "raw", "-r", rate, "-e", "signed", "-b", "16", "-c", "1", "-"},
|
||||
wantStdout: true,
|
||||
}, nil
|
||||
case PlatformWindows:
|
||||
device := opts.WindowsAudioDevice
|
||||
if device == "" {
|
||||
detected, err := detectWindowsAudioDevice(opts.runOutput)
|
||||
if err != nil {
|
||||
return commandSpec{}, err
|
||||
}
|
||||
device = detected
|
||||
}
|
||||
return commandSpec{
|
||||
name: "ffmpeg",
|
||||
args: []string{"-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + device,
|
||||
"-ac", "1", "-ar", rate, "-f", "s16le", "-"},
|
||||
wantStdout: true,
|
||||
stopViaStdin: "q",
|
||||
}, nil
|
||||
}
|
||||
return commandSpec{}, fmt.Errorf("streaming capture is not supported on platform %q", opts.Platform)
|
||||
}
|
||||
|
||||
// StartStreaming begins continuous PCM capture with the §12 safety nets: a
|
||||
// hard max-duration cap and Go-side trailing-silence auto-stop (the streaming
|
||||
// analog of SoX's silence effect — we see the samples, so we measure them).
|
||||
func (r *recorder) StartStreaming() (<-chan []byte, func() error, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.recording {
|
||||
return nil, nil, errAlreadyRecording
|
||||
}
|
||||
spec, err := streamCommand(r.opts)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
proc, stdout, err := r.opts.starter(spec)
|
||||
if err != nil {
|
||||
return nil, nil, startError(spec.name, r.opts.Platform, err, nil)
|
||||
}
|
||||
if stdout == nil {
|
||||
_ = proc.Kill()
|
||||
return nil, nil, errors.New("capture process has no stdout pipe")
|
||||
}
|
||||
r.recording = true
|
||||
|
||||
chunkBytes := chunkSizeBytes(r.opts.SampleRate)
|
||||
chunks := make(chan []byte, 8)
|
||||
done := make(chan struct{})
|
||||
var stopOnce sync.Once
|
||||
stop := func() error {
|
||||
stopOnce.Do(func() {
|
||||
close(done)
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
_ = waitWithTimeout(proc, stopGrace)
|
||||
_ = stdout.Close()
|
||||
r.mu.Lock()
|
||||
r.recording = false
|
||||
r.mu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Max-duration cap: streaming has no tool-side -d/-t equivalent that also
|
||||
// flushes stdout cleanly everywhere, so the cap runs on our side of the pipe.
|
||||
capTimer := time.AfterFunc(r.opts.MaxDuration, func() { _ = stop() })
|
||||
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
defer capTimer.Stop()
|
||||
// Tear down on ANY exit — including the capture process ending on its own
|
||||
// (EOF/crash), which the loop below returns on without going through an
|
||||
// explicit stop(). stop() is idempotent (sync.Once), so this is safe even
|
||||
// when the session was already stopped externally; it clears r.recording and
|
||||
// closes proc/stdout so a self-terminating device doesn't wedge the recorder.
|
||||
defer func() { _ = stop() }()
|
||||
threshold := int16(32767 * silenceThresholdPct / 100)
|
||||
silentChunks, speechSeen := 0, false
|
||||
silenceLimit := int(silenceHold / chunkInterval)
|
||||
for {
|
||||
buf := make([]byte, chunkBytes)
|
||||
n, err := readFullOrEOF(stdout, buf)
|
||||
if n > 0 {
|
||||
chunk := buf[:n]
|
||||
if r.opts.SilenceAutoStop {
|
||||
if chunkPeak(chunk) >= threshold {
|
||||
speechSeen = true
|
||||
silentChunks = 0
|
||||
} else if speechSeen {
|
||||
silentChunks++
|
||||
if silentChunks >= silenceLimit {
|
||||
go func() { _ = stop() }()
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case chunks <- chunk:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return chunks, stop, nil
|
||||
}
|
||||
|
||||
// chunkSizeBytes is 50ms of PCM16 mono at the given rate — small chunks mean
|
||||
// faster time-to-first-partial (§6a).
|
||||
func chunkSizeBytes(sampleRate int) int {
|
||||
return sampleRate * 2 / int(time.Second/chunkInterval)
|
||||
}
|
||||
|
||||
// readFullOrEOF fills buf from r, returning what it read and any terminal
|
||||
// error. A short final read (pipe closing mid-chunk) still returns its bytes.
|
||||
func readFullOrEOF(r interface{ Read([]byte) (int, error) }, buf []byte) (int, error) {
|
||||
total := 0
|
||||
for total < len(buf) {
|
||||
n, err := r.Read(buf[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// chunkPeak returns the loudest absolute sample in a little-endian PCM16 chunk.
|
||||
func chunkPeak(chunk []byte) int16 {
|
||||
var peak int16
|
||||
for i := 0; i+1 < len(chunk); i += 2 {
|
||||
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
|
||||
if s == -32768 {
|
||||
return 32767
|
||||
}
|
||||
if s < 0 {
|
||||
s = -s
|
||||
}
|
||||
if s > peak {
|
||||
peak = s
|
||||
}
|
||||
}
|
||||
return peak
|
||||
}
|
||||
|
||||
// levelFloorDB and levelCeilDB bound the RMS→meter mapping. Speech RMS is small
|
||||
// (roughly -45 dBFS quiet to -15 dBFS loud), so a LINEAR scale barely moves the
|
||||
// bars — a dB (logarithmic) scale, like real audio meters, is what makes normal
|
||||
// speech span the meter.
|
||||
const (
|
||||
levelFloorDB = -50.0 // ≈ near-silence → empty
|
||||
levelCeilDB = -15.0 // loud speech → full
|
||||
)
|
||||
|
||||
// ChunkLevel returns a perceptual 0..1 loudness for a little-endian PCM16 chunk,
|
||||
// driving the live recording waveform from the actual microphone input. It uses
|
||||
// an RMS→dBFS→linear mapping over [levelFloorDB, levelCeilDB].
|
||||
func ChunkLevel(chunk []byte) float64 {
|
||||
var sumSq float64
|
||||
n := 0
|
||||
for i := 0; i+1 < len(chunk); i += 2 {
|
||||
s := int16(uint16(chunk[i]) | uint16(chunk[i+1])<<8)
|
||||
f := float64(s) / 32768.0
|
||||
sumSq += f * f
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
rms := math.Sqrt(sumSq / float64(n))
|
||||
if rms <= 0 {
|
||||
return 0
|
||||
}
|
||||
db := 20 * math.Log10(rms) // dBFS (negative)
|
||||
level := (db - levelFloorDB) / (levelCeilDB - levelFloorDB)
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
if level > 1 {
|
||||
level = 1
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func maxSeconds(d time.Duration) int {
|
||||
sec := int(d / time.Second)
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
// detectWindowsAudioDevice picks the first dshow audio capture device from
|
||||
// `ffmpeg -list_devices`. dshow has no "default" alias, so a concrete device
|
||||
// name is required; stt.windowsAudioDevice overrides the auto-pick.
|
||||
func detectWindowsAudioDevice(runOutput commandOutputRunner) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
out, _ := runOutput(ctx, "ffmpeg", "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy")
|
||||
// Listing always "fails" (dummy isn't a real input); the device table is
|
||||
// in the output regardless. Lines look like: [dshow @ ...] "Mic name" (audio)
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.Contains(line, "(audio)") {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(line, `"`)
|
||||
if start < 0 {
|
||||
continue
|
||||
}
|
||||
rest := line[start+1:]
|
||||
end := strings.Index(rest, `"`)
|
||||
if end <= 0 {
|
||||
continue
|
||||
}
|
||||
return rest[:end], nil
|
||||
}
|
||||
return "", &SetupError{
|
||||
Tool: "ffmpeg",
|
||||
Hint: "no dshow audio capture device found — set stt.windowsAudioDevice to your microphone's name (list with: ffmpeg -list_devices true -f dshow -i dummy)",
|
||||
}
|
||||
}
|
||||
|
||||
// startError maps a failed capture-tool launch to setup guidance: a missing
|
||||
// binary is an expected unconfigured state (matching internal/lsp's
|
||||
// missing-server philosophy), not an internal error.
|
||||
func startError(tool string, platform Platform, err error, output []byte) error {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return &SetupError{Tool: tool, Hint: installHint(platform)}
|
||||
}
|
||||
if len(output) > 0 {
|
||||
return fmt.Errorf("starting %s: %w (%s)", tool, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return fmt.Errorf("starting %s: %w", tool, err)
|
||||
}
|
||||
|
||||
func installHint(platform Platform) string {
|
||||
switch platform {
|
||||
case PlatformLinux:
|
||||
return "install alsa-utils to enable dictation (e.g. sudo dnf install alsa-utils / sudo apt install alsa-utils)"
|
||||
case PlatformDarwin:
|
||||
return "install sox to enable dictation (brew install sox)"
|
||||
case PlatformWindows:
|
||||
return "install ffmpeg and ensure it is on PATH to enable dictation"
|
||||
case PlatformTermux:
|
||||
return "run `pkg install termux-api` and install the Termux:API companion app to enable dictation"
|
||||
}
|
||||
return "dictation is not supported on this platform"
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProcess is a processHandle whose stdout is driven from an in-memory
|
||||
// buffer, mirroring how internal/lsp/manager_test.go stubs its subprocess over
|
||||
// in-memory pipes rather than spawning a real one.
|
||||
type fakeProcess struct {
|
||||
stopped bool
|
||||
killed bool
|
||||
waitErr error
|
||||
stopErr error
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newFakeProcess() *fakeProcess { return &fakeProcess{done: make(chan struct{})} }
|
||||
|
||||
func (f *fakeProcess) StopGracefully() error {
|
||||
f.stopped = true
|
||||
f.closeOnce.Do(func() { close(f.done) })
|
||||
return f.stopErr
|
||||
}
|
||||
func (f *fakeProcess) Wait() error {
|
||||
<-f.done
|
||||
return f.waitErr
|
||||
}
|
||||
func (f *fakeProcess) Kill() error {
|
||||
f.killed = true
|
||||
f.closeOnce.Do(func() { close(f.done) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// captured records the last spec a starter was asked to launch, so tests can
|
||||
// assert the exact discrete argv per platform.
|
||||
type captured struct {
|
||||
spec commandSpec
|
||||
}
|
||||
|
||||
// fileWritingStarter simulates a record-to-file batch tool: on Start it writes
|
||||
// fixture bytes to the output path (last positional arg for arecord/sox, or the
|
||||
// -y target for ffmpeg) and returns a process that "records" until stopped.
|
||||
func fileWritingStarter(t *testing.T, cap *captured, fixture []byte) processStarter {
|
||||
t.Helper()
|
||||
return func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
cap.spec = spec
|
||||
path := outputPath(spec)
|
||||
if path == "" {
|
||||
t.Fatalf("could not determine output path from argv: %v", spec.args)
|
||||
}
|
||||
if err := os.WriteFile(path, fixture, 0o600); err != nil {
|
||||
t.Fatalf("fixture write: %v", err)
|
||||
}
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func outputPath(spec commandSpec) string {
|
||||
for i, a := range spec.args {
|
||||
if a == "-y" && i+1 < len(spec.args) {
|
||||
return spec.args[i+1]
|
||||
}
|
||||
}
|
||||
// arecord/sox: the path is the last arg (sox appends trim/silence effects
|
||||
// after it, so scan from the front for the first existing-dir target).
|
||||
last := spec.args[len(spec.args)-1]
|
||||
if filepath.IsAbs(last) {
|
||||
return last
|
||||
}
|
||||
for _, a := range spec.args {
|
||||
if filepath.IsAbs(a) && filepath.Ext(a) != "" {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wavFixture() []byte {
|
||||
// Minimal valid RIFF/WAVE header + a little data, enough for SniffFormat.
|
||||
var b bytes.Buffer
|
||||
b.WriteString("RIFF")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(36))
|
||||
b.WriteString("WAVE")
|
||||
b.WriteString("fmt ")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(16))
|
||||
b.Write(make([]byte, 16))
|
||||
b.WriteString("data")
|
||||
binary.Write(&b, binary.LittleEndian, uint32(4))
|
||||
b.Write([]byte{1, 2, 3, 4})
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func testTempDir(t *testing.T) func() (string, error) {
|
||||
dir := t.TempDir()
|
||||
return func() (string, error) { return dir, nil }
|
||||
}
|
||||
|
||||
func TestBatchRecordLinuxArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
fixture := wavFixture()
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, cap, fixture),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
got, err := r.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, fixture) {
|
||||
t.Fatalf("audio mismatch: got %d bytes", len(got))
|
||||
}
|
||||
if cap.spec.name != "arecord" {
|
||||
t.Errorf("tool = %q, want arecord", cap.spec.name)
|
||||
}
|
||||
assertArgs(t, cap.spec.args, "-f", "S16_LE")
|
||||
assertArgs(t, cap.spec.args, "-r", "16000")
|
||||
assertArgs(t, cap.spec.args, "-c", "1")
|
||||
}
|
||||
|
||||
func TestBatchRecordDarwinArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformDarwin,
|
||||
SilenceAutoStop: true,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if cap.spec.name != "sox" {
|
||||
t.Errorf("tool = %q, want sox", cap.spec.name)
|
||||
}
|
||||
if !containsSubseq(cap.spec.args, []string{"silence", "1", "0.1", "3%", "1", "2.0", "3%"}) {
|
||||
t.Errorf("expected SoX silence auto-stop effect in argv: %v", cap.spec.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRecordWindowsArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformWindows,
|
||||
WindowsAudioDevice: "Microphone (Realtek)",
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if cap.spec.name != "ffmpeg" {
|
||||
t.Errorf("tool = %q, want ffmpeg", cap.spec.name)
|
||||
}
|
||||
assertArgs(t, cap.spec.args, "-i", "audio=Microphone (Realtek)")
|
||||
if cap.spec.stopViaStdin != "q" {
|
||||
t.Errorf("ffmpeg should stop via stdin 'q', got %q", cap.spec.stopViaStdin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRecordTermuxUsesCommandRunner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var calls [][]string
|
||||
runOutput := func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
if len(args) > 0 && args[0] == "-l" {
|
||||
// The -f target is the last arg; write the fixture there.
|
||||
path := args[len(args)-1]
|
||||
_ = os.WriteFile(path, m4aFixture(), 0o600)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformTermux,
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
t.Fatal("termux must not use the process starter")
|
||||
return nil, nil, nil
|
||||
},
|
||||
runOutput: runOutput,
|
||||
tempDir: func() (string, error) { return dir, nil },
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
got, err := r.Stop()
|
||||
if err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if SniffFormat(got) != FormatM4A {
|
||||
t.Errorf("expected m4a audio, got format %q", SniffFormat(got))
|
||||
}
|
||||
if len(calls) != 2 || calls[0][0] != "termux-microphone-record" || calls[1][len(calls[1])-1] != "-q" {
|
||||
t.Errorf("unexpected termux calls: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartMissingBinaryReturnsSetupError(t *testing.T) {
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return nil, nil, exec.ErrNotFound
|
||||
},
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
err := r.Start()
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing binary, got %v", err)
|
||||
}
|
||||
if setupErr.Tool != "arecord" {
|
||||
t.Errorf("SetupError.Tool = %q, want arecord", setupErr.Tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoubleStartRejected(t *testing.T) {
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, &captured{}, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := r.Start(); !errors.Is(err, errAlreadyRecording) {
|
||||
t.Errorf("second Start should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingChunksAndStop(t *testing.T) {
|
||||
// 300ms of PCM16 @16kHz = 6 chunks of 50ms. Feed a fixed buffer, then EOF.
|
||||
pcm := make([]byte, chunkSizeBytes(16000)*6)
|
||||
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
if !spec.wantStdout {
|
||||
t.Fatal("streaming spec must request stdout")
|
||||
}
|
||||
return newFakeProcess(), io.NopCloser(bytes.NewReader(pcm)), nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: starter,
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
var total int
|
||||
for c := range chunks {
|
||||
total += len(c)
|
||||
}
|
||||
_ = stop()
|
||||
if total != len(pcm) {
|
||||
t.Errorf("streamed %d bytes, want %d", total, len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingStopIdempotent(t *testing.T) {
|
||||
// A blocking reader so the capture goroutine parks until we stop it.
|
||||
pr, pw := io.Pipe()
|
||||
defer pw.Close()
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return newFakeProcess(), pr, nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{Platform: PlatformLinux, starter: starter, tempDir: testTempDir(t)})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
if err := stop(); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := stop(); err != nil {
|
||||
t.Fatalf("second stop should be a no-op, got %v", err)
|
||||
}
|
||||
// Draining the channel must terminate now that we've stopped.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
for range chunks {
|
||||
}
|
||||
close(drained)
|
||||
}()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("chunks channel did not close after stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMaxDurationCapInArgv(t *testing.T) {
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
MaxDuration: 90 * time.Second,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
// arecord's -d bounds a runaway recording even if Stop is never called.
|
||||
assertArgs(t, cap.spec.args, "-d", "90")
|
||||
}
|
||||
|
||||
func TestRecordedTempFileDeletedAfterStop(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cap := &captured{}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
starter: fileWritingStarter(t, cap, wavFixture()),
|
||||
tempDir: func() (string, error) { return dir, nil },
|
||||
})
|
||||
if err := r.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if _, err := r.Stop(); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("recorded audio should be deleted after Stop; found %d files", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingSilenceAutoStop(t *testing.T) {
|
||||
// Speech (loud) then a run of silence longer than silenceHold must auto-stop.
|
||||
rate := 16000
|
||||
chunk := chunkSizeBytes(rate)
|
||||
loud := make([]byte, chunk)
|
||||
for i := 0; i+1 < len(loud); i += 2 {
|
||||
loud[i], loud[i+1] = 0x00, 0x40 // +16384, well above threshold
|
||||
}
|
||||
silence := make([]byte, chunk) // zeros
|
||||
var buf []byte
|
||||
buf = append(buf, loud...)
|
||||
silentChunks := int(silenceHold/chunkInterval) + 3
|
||||
for i := 0; i < silentChunks; i++ {
|
||||
buf = append(buf, silence...)
|
||||
}
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
return newFakeProcess(), io.NopCloser(bytes.NewReader(buf)), nil
|
||||
}
|
||||
r := NewRecorder(RecorderOptions{
|
||||
Platform: PlatformLinux,
|
||||
SampleRate: rate,
|
||||
SilenceAutoStop: true,
|
||||
starter: starter,
|
||||
tempDir: testTempDir(t),
|
||||
})
|
||||
chunks, stop, err := r.StartStreaming()
|
||||
if err != nil {
|
||||
t.Fatalf("StartStreaming: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
// Draining must terminate on its own once silence auto-stop fires.
|
||||
drained := make(chan struct{})
|
||||
go func() {
|
||||
for range chunks {
|
||||
}
|
||||
close(drained)
|
||||
}()
|
||||
select {
|
||||
case <-drained:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("silence auto-stop did not end the stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPeak(t *testing.T) {
|
||||
loud := []byte{0x00, 0x40} // +16384
|
||||
if got := chunkPeak(loud); got != 16384 {
|
||||
t.Errorf("peak = %d, want 16384", got)
|
||||
}
|
||||
quiet := []byte{0x10, 0x00} // +16
|
||||
if got := chunkPeak(quiet); got != 16 {
|
||||
t.Errorf("peak = %d, want 16", got)
|
||||
}
|
||||
min := []byte{0x00, 0x80} // -32768 -> clamped to 32767
|
||||
if got := chunkPeak(min); got != 32767 {
|
||||
t.Errorf("peak = %d, want 32767 (clamp)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectWindowsAudioDevice(t *testing.T) {
|
||||
listing := `[dshow @ 0000] DirectShow audio devices
|
||||
[dshow @ 0000] "Microphone (USB Audio)" (audio)
|
||||
[dshow @ 0000] Alternative name "@device_cm_..."`
|
||||
runOutput := func(context.Context, string, ...string) ([]byte, error) { return []byte(listing), nil }
|
||||
got, err := detectWindowsAudioDevice(runOutput)
|
||||
if err != nil {
|
||||
t.Fatalf("detect: %v", err)
|
||||
}
|
||||
if got != "Microphone (USB Audio)" {
|
||||
t.Errorf("device = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffFormat(t *testing.T) {
|
||||
if SniffFormat(wavFixture()) != FormatWAV {
|
||||
t.Error("wav not detected")
|
||||
}
|
||||
if SniffFormat(m4aFixture()) != FormatM4A {
|
||||
t.Error("m4a not detected")
|
||||
}
|
||||
if SniffFormat([]byte("nope")) != FormatUnknown {
|
||||
t.Error("unknown not detected")
|
||||
}
|
||||
}
|
||||
|
||||
func m4aFixture() []byte {
|
||||
b := make([]byte, 16)
|
||||
copy(b[4:], []byte("ftyp"))
|
||||
copy(b[8:], []byte("M4A "))
|
||||
return b
|
||||
}
|
||||
|
||||
func assertArgs(t *testing.T, args []string, want ...string) {
|
||||
t.Helper()
|
||||
if !containsSubseq(args, want) {
|
||||
t.Errorf("argv %v missing subsequence %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubseq(hay, needle []string) bool {
|
||||
if len(needle) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if equalSlice(hay[i:i+len(needle)], needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equalSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestChunkLevel(t *testing.T) {
|
||||
// Silence → ~0.
|
||||
silence := make([]byte, 640)
|
||||
if lvl := ChunkLevel(silence); lvl > 0.01 {
|
||||
t.Errorf("silence level = %f, want ~0", lvl)
|
||||
}
|
||||
// A loud full-scale tone → clamped to 1.
|
||||
loud := make([]byte, 640)
|
||||
for i := 0; i+1 < len(loud); i += 2 {
|
||||
loud[i], loud[i+1] = 0xFF, 0x7F // +32767
|
||||
}
|
||||
if lvl := ChunkLevel(loud); lvl < 0.99 {
|
||||
t.Errorf("full-scale level = %f, want ~1", lvl)
|
||||
}
|
||||
// A normal speech-level signal (~-30 dBFS) should land mid-meter, not pinned
|
||||
// to the bottom (the whole point of the dB mapping).
|
||||
mid := make([]byte, 640)
|
||||
for i := 0; i+1 < len(mid); i += 2 {
|
||||
mid[i], mid[i+1] = byte(1000&0xFF), byte(1000>>8) // ≈ -30 dBFS
|
||||
}
|
||||
if lvl := ChunkLevel(mid); lvl < 0.4 || lvl > 0.75 {
|
||||
t.Errorf("normal-speech level = %f, want a visible mid-range value", lvl)
|
||||
}
|
||||
// A quiet signal (~-45 dBFS) should be low but non-zero.
|
||||
quiet := make([]byte, 640)
|
||||
for i := 0; i+1 < len(quiet); i += 2 {
|
||||
quiet[i], quiet[i+1] = byte(180&0xFF), byte(180>>8)
|
||||
}
|
||||
if lvl := ChunkLevel(quiet); lvl <= 0 || lvl > 0.35 {
|
||||
t.Errorf("quiet level = %f, want a small non-zero value", lvl)
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandSpec describes one capture-process invocation. Argv is always
|
||||
// discrete — never a shell string (the same invariant documented in
|
||||
// internal/imageinput/clipboard.go).
|
||||
type commandSpec struct {
|
||||
name string
|
||||
args []string
|
||||
// wantStdout wires up a stdout pipe (streaming capture reads raw PCM).
|
||||
wantStdout bool
|
||||
// stopViaStdin, when non-empty, makes graceful stop write this to the
|
||||
// process's stdin instead of signaling — ffmpeg finalizes cleanly on "q",
|
||||
// and Windows has no os.Interrupt delivery anyway.
|
||||
stopViaStdin string
|
||||
}
|
||||
|
||||
// processHandle is a started capture process. Injectable via processStarter so
|
||||
// unit tests fake OS-boundary behavior, mirroring internal/lsp's serverStarter
|
||||
// seam (this package's clipboard.go-style shell-outs get the test seam that
|
||||
// file lacks).
|
||||
type processHandle interface {
|
||||
// StopGracefully asks the tool to finish and flush output (SIGINT, or the
|
||||
// spec's stdin stop text). The recorder falls back to Kill on timeout.
|
||||
StopGracefully() error
|
||||
Wait() error
|
||||
Kill() error
|
||||
}
|
||||
|
||||
// processStarter launches a capture process, returning its handle and — when
|
||||
// the spec asks for it — its stdout pipe.
|
||||
type processStarter func(spec commandSpec) (processHandle, io.ReadCloser, error)
|
||||
|
||||
// commandOutputRunner runs a short one-shot command to completion and returns
|
||||
// its combined output. Used for helper invocations that aren't recordings
|
||||
// (Termux start/stop, Windows ffmpeg device listing) and the offline
|
||||
// transcription exec — ctx lets a caller cancel/timeout a wedged process.
|
||||
type commandOutputRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
func startProcess(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
cmd := exec.Command(spec.name, spec.args...)
|
||||
var stdout io.ReadCloser
|
||||
if spec.wantStdout {
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stdout = pipe
|
||||
}
|
||||
var stdin io.WriteCloser
|
||||
if spec.stopViaStdin != "" {
|
||||
pipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
stdin = pipe
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
// StdoutPipe/StdinPipe allocate fds the runtime only reclaims via
|
||||
// Wait; on Start failure the process is never established, so close
|
||||
// any pipes we already opened to avoid leaking them.
|
||||
if stdout != nil {
|
||||
_ = stdout.Close()
|
||||
}
|
||||
if stdin != nil {
|
||||
_ = stdin.Close()
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return &realProcess{cmd: cmd, stdin: stdin, stopText: spec.stopViaStdin}, stdout, nil
|
||||
}
|
||||
|
||||
type realProcess struct {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stopText string
|
||||
}
|
||||
|
||||
func (p *realProcess) StopGracefully() error {
|
||||
if p.stopText != "" && p.stdin != nil {
|
||||
_, err := io.WriteString(p.stdin, p.stopText)
|
||||
closeErr := p.stdin.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
// os.Interrupt is how arecord/sox finalize their WAV header. Unsupported
|
||||
// on Windows, but the only Windows tool (ffmpeg) stops via stdin above.
|
||||
return p.cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
|
||||
func (p *realProcess) Wait() error { return p.cmd.Wait() }
|
||||
|
||||
func (p *realProcess) Kill() error { return p.cmd.Process.Kill() }
|
||||
|
||||
func runCommandOutput(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
return out.Bytes(), err
|
||||
}
|
||||
|
||||
// waitWithTimeout waits for the process, killing it if it ignores the graceful
|
||||
// stop for longer than the grace window. Capture tools exit on SIGINT/"q"
|
||||
// almost instantly; a hang here means a wedged device driver, and a leaked
|
||||
// recording process holds the microphone open.
|
||||
func waitWithTimeout(proc processHandle, grace time.Duration) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- proc.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(grace):
|
||||
_ = proc.Kill()
|
||||
return <-done
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultServerPort is the localhost port the sherpa-onnx streaming server binds.
|
||||
const DefaultServerPort = 6006
|
||||
|
||||
// ServerConfig configures the warm sherpa-onnx streaming server (§6a).
|
||||
type ServerConfig struct {
|
||||
// Binary is the streaming server executable (default
|
||||
// "sherpa-onnx-online-websocket-server"); looked up on PATH.
|
||||
Binary string
|
||||
// ModelPath is the sherpa-onnx streaming model directory (transducer:
|
||||
// encoder/decoder/joiner + tokens).
|
||||
ModelPath string
|
||||
Port int
|
||||
NumThreads int
|
||||
|
||||
// Test seams.
|
||||
starter processStarter
|
||||
healthCheck func(ctx context.Context, port int) error
|
||||
aliveCheck func(port int) bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// ServerManager owns one long-lived sherpa-onnx streaming server, spawned lazily
|
||||
// on first streaming use and kept warm for the session — a websocket server with
|
||||
// ~1-2s startup latency can't be respawned per utterance (§6a). Its lifecycle
|
||||
// mirrors internal/lsp's Manager: lazy start, health-checked reuse, one restart
|
||||
// on a crashed process, torn down on exit. Safe for concurrent use.
|
||||
type ServerManager struct {
|
||||
cfg ServerConfig
|
||||
|
||||
mu sync.Mutex
|
||||
proc processHandle
|
||||
url string
|
||||
}
|
||||
|
||||
// NewServerManager builds a manager. Construction is side-effect free; the
|
||||
// server is only spawned on the first EnsureRunning.
|
||||
func NewServerManager(cfg ServerConfig) *ServerManager {
|
||||
if cfg.Binary == "" {
|
||||
cfg.Binary = "sherpa-onnx-online-websocket-server"
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = DefaultServerPort
|
||||
}
|
||||
if cfg.starter == nil {
|
||||
cfg.starter = startProcess
|
||||
}
|
||||
if cfg.healthCheck == nil {
|
||||
cfg.healthCheck = dialHealthCheck
|
||||
}
|
||||
if cfg.aliveCheck == nil {
|
||||
cfg.aliveCheck = func(port int) bool { return portListening(port) }
|
||||
}
|
||||
if cfg.now == nil {
|
||||
cfg.now = time.Now
|
||||
}
|
||||
return &ServerManager{cfg: cfg}
|
||||
}
|
||||
|
||||
// EnsureRunning returns the websocket URL of a live server, starting one if
|
||||
// needed. A previously-started server whose process has since died is reaped and
|
||||
// replaced (mirroring the LSP manager's crashed-session eviction).
|
||||
func (m *ServerManager) EnsureRunning(ctx context.Context) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.proc != nil {
|
||||
if m.processAlive() {
|
||||
return m.url, nil
|
||||
}
|
||||
// The cached server died; reap it so a fresh one starts below.
|
||||
_ = m.proc.Kill()
|
||||
m.proc = nil
|
||||
m.url = ""
|
||||
}
|
||||
|
||||
spec, err := onlineServerCommand(m.cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
proc, _, err := m.cfg.starter(spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", &SetupError{
|
||||
Tool: m.cfg.Binary,
|
||||
Hint: "install sherpa-onnx and put its online-websocket-server binary on PATH (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("starting sherpa-onnx server: %w", err)
|
||||
}
|
||||
|
||||
// Health-check the port before handing back a URL — the server takes ~1-2s to
|
||||
// bind, and a client connecting too early would fail (§6a).
|
||||
if err := m.cfg.healthCheck(ctx, m.cfg.Port); err != nil {
|
||||
_ = proc.Kill()
|
||||
return "", fmt.Errorf("sherpa-onnx server did not become ready: %w", err)
|
||||
}
|
||||
m.proc = proc
|
||||
m.url = fmt.Sprintf("ws://127.0.0.1:%d", m.cfg.Port)
|
||||
return m.url, nil
|
||||
}
|
||||
|
||||
// processAlive reports whether the tracked server is still reachable. Probing
|
||||
// the port (rather than the process handle) reflects real reachability and is
|
||||
// the seam tests replace.
|
||||
func (m *ServerManager) processAlive() bool {
|
||||
return m.cfg.aliveCheck(m.cfg.Port)
|
||||
}
|
||||
|
||||
// portListening reports whether something accepts TCP connections on the port.
|
||||
func portListening(port int) bool {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// Shutdown stops the running server. Called alongside the LSP manager's shutdown
|
||||
// on exit — a leaked warm server holds a port and CPU.
|
||||
func (m *ServerManager) Shutdown(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
proc := m.proc
|
||||
m.proc = nil
|
||||
m.url = ""
|
||||
m.mu.Unlock()
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
if err := proc.StopGracefully(); err != nil {
|
||||
_ = proc.Kill()
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- waitWithTimeout(proc, stopGrace) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
_ = proc.Kill()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// URL returns the current server URL without starting one ("" when not running).
|
||||
func (m *ServerManager) URL() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.url
|
||||
}
|
||||
|
||||
// SetModelPath updates the model directory the server launches with, for a
|
||||
// mid-session config change (e.g. an F9 auto-download). It takes effect the next
|
||||
// time the server (re)starts; a running server is left as-is.
|
||||
func (m *ServerManager) SetModelPath(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if path != "" {
|
||||
m.cfg.ModelPath = path
|
||||
}
|
||||
}
|
||||
|
||||
// onlineServerCommand builds the streaming-server argv from the model directory.
|
||||
// The online server uses a streaming transducer (encoder/decoder/joiner) plus
|
||||
// tokens — the shape the sherpa-onnx docs' example uses (§6a).
|
||||
func onlineServerCommand(cfg ServerConfig) (commandSpec, error) {
|
||||
if strings.TrimSpace(cfg.ModelPath) == "" {
|
||||
return commandSpec{}, &SetupError{
|
||||
Tool: "local STT streaming model",
|
||||
Hint: "set stt.localModelPath to a sherpa-onnx streaming model directory (see docs/dictation.md)",
|
||||
}
|
||||
}
|
||||
files, err := onnxFilesIn(cfg.ModelPath)
|
||||
if err != nil {
|
||||
return commandSpec{}, &SetupError{Tool: "local STT streaming model", Hint: err.Error()}
|
||||
}
|
||||
tokens := findTokensFile(files)
|
||||
// A streaming model ships encoder/decoder/joiner, often BOTH an int8 and an
|
||||
// fp32 copy of each. Pick a CONSISTENT set — all int8 or all fp32 — because
|
||||
// mixing (int8 encoder + fp32 decoder) is invalid. Prefer int8 (smaller,
|
||||
// faster) when a full int8 set is present.
|
||||
set := consistentQuantizedSet(files, "encoder", "decoder", "joiner")
|
||||
var encoder, decoder, joiner string
|
||||
if set != nil {
|
||||
encoder, decoder, joiner = set[0], set[1], set[2]
|
||||
}
|
||||
if tokens == "" || encoder == "" || decoder == "" || joiner == "" {
|
||||
return commandSpec{}, &SetupError{
|
||||
Tool: "local STT streaming model",
|
||||
Hint: fmt.Sprintf("streaming needs a transducer model (encoder/decoder/joiner .onnx + tokens.txt) in %q; see docs/dictation.md", cfg.ModelPath),
|
||||
}
|
||||
}
|
||||
args := []string{
|
||||
"--port=" + strconv.Itoa(cfg.Port),
|
||||
"--tokens=" + tokens,
|
||||
"--encoder=" + encoder,
|
||||
"--decoder=" + decoder,
|
||||
"--joiner=" + joiner,
|
||||
"--decoding-method=greedy_search",
|
||||
}
|
||||
if cfg.NumThreads > 0 {
|
||||
args = append(args, "--num-threads="+strconv.Itoa(cfg.NumThreads))
|
||||
}
|
||||
return commandSpec{name: cfg.Binary, args: args}, nil
|
||||
}
|
||||
|
||||
func onnxFilesIn(dir string) (map[string]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stt.localModelPath %q is not readable: %v", dir, err)
|
||||
}
|
||||
files := map[string]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
files[strings.ToLower(e.Name())] = dir + string(os.PathSeparator) + e.Name()
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// dialHealthCheck polls the server's TCP port until it accepts a connection or
|
||||
// the deadline passes.
|
||||
func dialHealthCheck(ctx context.Context, port int) error {
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("port %d not listening after 5s", port)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func streamingModelDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, f := range []string{"encoder.onnx", "decoder.onnx", "joiner.onnx", "tokens.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestServerManagerLazyStartAndReuse(t *testing.T) {
|
||||
var startCount int
|
||||
var mu sync.Mutex
|
||||
starter := func(spec commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
mu.Lock()
|
||||
startCount++
|
||||
mu.Unlock()
|
||||
if spec.name != "sherpa-onnx-online-websocket-server" {
|
||||
t.Errorf("binary = %q", spec.name)
|
||||
}
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
alive := true
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
Port: 6006,
|
||||
starter: starter,
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return alive },
|
||||
})
|
||||
|
||||
url, err := m.EnsureRunning(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureRunning: %v", err)
|
||||
}
|
||||
if url != "ws://127.0.0.1:6006" {
|
||||
t.Errorf("url = %q", url)
|
||||
}
|
||||
// Second call reuses the warm server (alive) — no new start.
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatalf("EnsureRunning reuse: %v", err)
|
||||
}
|
||||
if startCount != 1 {
|
||||
t.Errorf("expected 1 start (warm reuse), got %d", startCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerRestartsCrashedServer(t *testing.T) {
|
||||
var startCount int
|
||||
starter := func(commandSpec) (processHandle, io.ReadCloser, error) {
|
||||
startCount++
|
||||
return newFakeProcess(), nil, nil
|
||||
}
|
||||
alive := true
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: starter,
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return alive },
|
||||
})
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alive = false // simulate a crashed server
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if startCount != 2 {
|
||||
t.Errorf("expected a restart (2 starts), got %d", startCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerMissingBinaryIsSetupError(t *testing.T) {
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return nil, nil, exec.ErrNotFound },
|
||||
})
|
||||
_, err := m.EnsureRunning(context.Background())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerMissingModelIsSetupError(t *testing.T) {
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: t.TempDir(), // empty dir, no onnx/tokens
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return newFakeProcess(), nil, nil },
|
||||
})
|
||||
_, err := m.EnsureRunning(context.Background())
|
||||
var setupErr *SetupError
|
||||
if !errors.As(err, &setupErr) {
|
||||
t.Fatalf("want *SetupError for missing streaming model, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerManagerShutdown(t *testing.T) {
|
||||
proc := newFakeProcess()
|
||||
m := NewServerManager(ServerConfig{
|
||||
ModelPath: streamingModelDir(t),
|
||||
starter: func(commandSpec) (processHandle, io.ReadCloser, error) { return proc, nil, nil },
|
||||
healthCheck: func(context.Context, int) error { return nil },
|
||||
aliveCheck: func(int) bool { return true },
|
||||
})
|
||||
if _, err := m.EnsureRunning(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Shutdown(context.Background()); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
if !proc.stopped && !proc.killed {
|
||||
t.Error("Shutdown should stop the server process")
|
||||
}
|
||||
if m.URL() != "" {
|
||||
t.Error("URL should be empty after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineServerCommandBuildsTransducerArgs(t *testing.T) {
|
||||
spec, err := onlineServerCommand(ServerConfig{Binary: "sherpa-onnx-online-websocket-server", ModelPath: streamingModelDir(t), Port: 6007})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := ""
|
||||
for _, a := range spec.args {
|
||||
joined += a + " "
|
||||
}
|
||||
for _, want := range []string{"--port=6007", "--tokens=", "--encoder=", "--decoder=", "--joiner=", "--decoding-method=greedy_search"} {
|
||||
if !contains(joined, want) {
|
||||
t.Errorf("missing %q in args: %s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCM16ToFloat32LE(t *testing.T) {
|
||||
// int16 max (32767) and min (-32768), little-endian.
|
||||
pcm := []byte{0xFF, 0x7F, 0x00, 0x80}
|
||||
out := pcm16ToFloat32LE(pcm)
|
||||
if len(out) != 8 {
|
||||
t.Fatalf("expected 8 bytes (2 float32), got %d", len(out))
|
||||
}
|
||||
f0 := math.Float32frombits(binary.LittleEndian.Uint32(out[0:4]))
|
||||
f1 := math.Float32frombits(binary.LittleEndian.Uint32(out[4:8]))
|
||||
if f0 < 0.99 || f0 > 1.0 {
|
||||
t.Errorf("32767 → %f, want ~1.0", f0)
|
||||
}
|
||||
if f1 != -1.0 {
|
||||
t.Errorf("-32768 → %f, want -1.0", f1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSherpaStreamResult(t *testing.T) {
|
||||
text, segment, final, ok := parseSherpaStreamResult([]byte(`{"text":" hello world ","segment":2,"is_final":true}`))
|
||||
if !ok || text != "hello world" || segment != 2 || !final {
|
||||
t.Errorf("parsed = (%q, %d, %v, %v)", text, segment, final, ok)
|
||||
}
|
||||
if _, _, _, ok := parseSherpaStreamResult([]byte("not json")); ok {
|
||||
t.Error("non-JSON should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(hay, needle string) bool {
|
||||
return len(needle) == 0 || (len(hay) >= len(needle) && indexOf(hay, needle) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(hay, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(hay); i++ {
|
||||
if hay[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Recorded audio is staged in a hardened per-user temp directory, mirroring
|
||||
// internal/tools/spill.go (PR #506): uid-suffixed name so users on a shared
|
||||
// /tmp cannot collide, 0700 perms, and a pre-existing path is only accepted
|
||||
// when it is a real directory (not a symlink that would redirect recordings)
|
||||
// owned by the current user. Files are removed as soon as their bytes are
|
||||
// read back after Stop — audio never lingers on disk.
|
||||
|
||||
func audioSpillRoot() string {
|
||||
name := "zero-dictation"
|
||||
if uid := os.Getuid(); uid >= 0 {
|
||||
name = fmt.Sprintf("zero-dictation-%d", uid)
|
||||
}
|
||||
return filepath.Join(os.TempDir(), name)
|
||||
}
|
||||
|
||||
func audioSpillDir() (string, error) {
|
||||
dir := audioSpillRoot()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// MkdirAll follows symlinks and leaves an existing directory untouched, so
|
||||
// verify what is actually at the path.
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("dictation temp path %s is not a directory", dir)
|
||||
}
|
||||
// MkdirAll only applies 0o700 to dirs it creates; an existing directory
|
||||
// (e.g. one created under a loose umask in a prior run) can be group- or
|
||||
// world-readable. Tighten it so staged audio (PII) is never visible to
|
||||
// other users on a shared /tmp.
|
||||
if info.Mode().Perm() != 0o700 {
|
||||
if err := os.Chmod(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("tightening dictation temp dir permissions: %w", err)
|
||||
}
|
||||
}
|
||||
if err := checkAudioDirOwner(info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalStreamingRealServer drives the full local streaming path against a
|
||||
// REAL sherpa-onnx-online-websocket-server + streaming model: spawn the server,
|
||||
// connect, feed a wav's PCM in 50ms chunks, and check the transcript. Opt-in:
|
||||
//
|
||||
// ZERO_STT_STREAM_SERVER=/path/to/sherpa-onnx-online-websocket-server \
|
||||
// ZERO_STT_STREAM_MODEL=/path/to/streaming-model-dir \
|
||||
// ZERO_STT_STREAM_WAV=/path/to/16k-mono.wav \
|
||||
// go test ./internal/dictation/ -run RealServer -v -timeout 5m
|
||||
func TestLocalStreamingRealServer(t *testing.T) {
|
||||
server := os.Getenv("ZERO_STT_STREAM_SERVER")
|
||||
model := os.Getenv("ZERO_STT_STREAM_MODEL")
|
||||
wav := os.Getenv("ZERO_STT_STREAM_WAV")
|
||||
if server == "" || model == "" || wav == "" {
|
||||
t.Skip("set ZERO_STT_STREAM_SERVER, ZERO_STT_STREAM_MODEL, ZERO_STT_STREAM_WAV to run the streaming smoke test")
|
||||
}
|
||||
raw, err := os.ReadFile(wav)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pcm := stripWavHeader(raw)
|
||||
|
||||
mgr := NewServerManager(ServerConfig{Binary: server, ModelPath: model, Port: 6100})
|
||||
defer mgr.Shutdown(context.Background())
|
||||
tr := NewLocalStreamingTranscriber(mgr)
|
||||
|
||||
chunks := make(chan []byte)
|
||||
go func() {
|
||||
defer close(chunks)
|
||||
size := chunkSizeBytes(DefaultSampleRate) // 50ms
|
||||
for off := 0; off < len(pcm); off += size {
|
||||
end := off + size
|
||||
if end > len(pcm) {
|
||||
end = len(pcm)
|
||||
}
|
||||
chunks <- pcm[off:end]
|
||||
time.Sleep(10 * time.Millisecond) // pace it a little, like a mic
|
||||
}
|
||||
}()
|
||||
|
||||
var partials int
|
||||
final, err := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) { partials++ })
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTranscribe: %v", err)
|
||||
}
|
||||
if final == "" {
|
||||
t.Fatal("streaming returned empty transcript")
|
||||
}
|
||||
t.Logf("streaming transcript (%d partials): %q", partials, final)
|
||||
}
|
||||
|
||||
// stripWavHeader drops a standard 44-byte PCM WAV header, returning the raw
|
||||
// int16 samples. Good enough for the canonical 16kHz/mono/16-bit test wavs.
|
||||
func stripWavHeader(b []byte) []byte {
|
||||
if len(b) > 44 && string(b[:4]) == "RIFF" {
|
||||
return b[44:]
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import "context"
|
||||
|
||||
// Transcriber turns captured audio into text. Batch (Transcribe) and streaming
|
||||
// (StreamTranscribe) are one interface but two independent code paths — a
|
||||
// batch-only provider (Groq/OpenAI, sherpa-onnx-offline) leaves
|
||||
// StreamTranscribe returning ErrStreamingUnsupported, and a streaming provider
|
||||
// still implements Transcribe for the batch/Termux fallback.
|
||||
type Transcriber interface {
|
||||
// Transcribe returns the final text for one recorded clip.
|
||||
Transcribe(ctx context.Context, audio []byte) (string, error)
|
||||
|
||||
// StreamTranscribe consumes PCM16 chunks and reports incremental results
|
||||
// via onPartial(text, final) as decoding proceeds, returning the complete
|
||||
// final transcript when the chunk channel closes.
|
||||
//
|
||||
// onPartial's text is the CURRENT best transcript of the utterance so far
|
||||
// (cumulative, not a delta) so the composer can replace the live region
|
||||
// wholesale; final marks a segment the provider considers settled.
|
||||
//
|
||||
// On a mid-stream failure (network drop for cloud, subprocess crash for
|
||||
// local) StreamTranscribe returns the best-effort text accumulated from
|
||||
// onPartial calls made so far PLUS a non-nil error — already-streamed text
|
||||
// is never discarded, only the untranscribed tail after the failure is lost.
|
||||
StreamTranscribe(ctx context.Context, chunks <-chan []byte, onPartial func(text string, final bool)) (string, error)
|
||||
}
|
||||
|
||||
// SampleRate lets a Transcriber declare the capture rate its wire format
|
||||
// expects, so the recorder can be built to match (OpenAI Realtime wants 24kHz;
|
||||
// everything else uses 16kHz). A Transcriber that does not implement this uses
|
||||
// DefaultSampleRate.
|
||||
type SampleRate interface {
|
||||
SampleRate() int
|
||||
}
|
||||
|
||||
// RequiredSampleRate returns the capture rate a Transcriber needs.
|
||||
func RequiredSampleRate(t Transcriber) int {
|
||||
if sr, ok := t.(SampleRate); ok {
|
||||
if rate := sr.SampleRate(); rate > 0 {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
return DefaultSampleRate
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package dictation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// CloudConfig configures a batch cloud transcriber. Groq and OpenAI share the
|
||||
// OpenAI-compatible /audio/transcriptions multipart endpoint — the design's
|
||||
// "each owns its endpoint/model specifics while sharing the request shape"
|
||||
// (§6). Key and base URL are resolved by the caller (TUI layer) from
|
||||
// config/credstore, keeping this type decoupled and unit-testable.
|
||||
type CloudConfig struct {
|
||||
Provider string // ProviderGroq or ProviderOpenAI, for error messages
|
||||
BaseURL string // e.g. https://api.groq.com/openai/v1
|
||||
APIKey string
|
||||
Model string // e.g. whisper-large-v3-turbo, whisper-1
|
||||
// Language optionally constrains recognition (ISO-639-1). Empty = auto.
|
||||
Language string
|
||||
|
||||
// HTTPClient is injectable for tests; nil uses providerio's shared,
|
||||
// stall-hardened client.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type cloudTranscriber struct {
|
||||
cfg CloudConfig
|
||||
}
|
||||
|
||||
// NewCloudTranscriber builds a batch transcriber for Groq or OpenAI.
|
||||
func NewCloudTranscriber(cfg CloudConfig) (Transcriber, error) {
|
||||
if strings.TrimSpace(cfg.APIKey) == "" {
|
||||
return nil, &SetupError{
|
||||
Tool: cfg.Provider + " API key",
|
||||
Hint: fmt.Sprintf("set a %s API key (run `zero auth`) to use cloud dictation", cfg.Provider),
|
||||
}
|
||||
}
|
||||
if cfg.Model == "" {
|
||||
return nil, fmt.Errorf("dictation: no model configured for provider %q", cfg.Provider)
|
||||
}
|
||||
base, err := providerio.NormalizeBaseURL(cfg.BaseURL, cfg.BaseURL, cfg.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.BaseURL = base
|
||||
return &cloudTranscriber{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (c *cloudTranscriber) Transcribe(ctx context.Context, audio []byte) (string, error) {
|
||||
if len(audio) == 0 {
|
||||
return "", errors.New("no audio to transcribe")
|
||||
}
|
||||
body, contentType, err := c.buildMultipart(audio)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
endpoint := c.cfg.BaseURL + "/audio/transcriptions"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||||
|
||||
resp, err := providerio.HTTPClient(c.cfg.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s transcription request failed: %w", c.cfg.Provider, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := providerio.ClassifiedError(resp.StatusCode, strings.TrimSpace(string(respBody)), c.cfg.APIKey)
|
||||
// A 401/403 is a fixable credential problem: surface it as a typed AuthError
|
||||
// so the TUI can offer an inline key prompt instead of a dead-end message.
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return "", &AuthError{Provider: c.cfg.Provider, Message: msg}
|
||||
}
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
var parsed struct {
|
||||
Text string `json:"text"`
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return "", fmt.Errorf("%s transcription: could not parse response: %w", c.cfg.Provider, err)
|
||||
}
|
||||
if parsed.Error.Message != "" {
|
||||
return "", fmt.Errorf("%s transcription error: %s", c.cfg.Provider, providerio.Redact(parsed.Error.Message, c.cfg.APIKey))
|
||||
}
|
||||
return strings.TrimSpace(parsed.Text), nil
|
||||
}
|
||||
|
||||
// buildMultipart assembles the transcription upload. The file field name and
|
||||
// the "model"/"response_format" fields match the OpenAI audio API that Groq
|
||||
// mirrors. The filename's extension tells the server the container type —
|
||||
// SniffFormat keeps WAV/M4A honest so Termux's AAC uploads work.
|
||||
func (c *cloudTranscriber) buildMultipart(audio []byte) ([]byte, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", SniffFormat(audio).FileName())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := fw.Write(audio); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.WriteField("model", c.cfg.Model); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.WriteField("response_format", "json"); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if c.cfg.Language != "" {
|
||||
if err := w.WriteField("language", c.cfg.Language); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
// StreamTranscribe is not supported for the batch cloud providers (Groq has no
|
||||
// streaming product; OpenAI batch is one-shot). Streaming uses the dedicated
|
||||
// Deepgram/OpenAI-Realtime transcribers instead.
|
||||
func (c *cloudTranscriber) StreamTranscribe(context.Context, <-chan []byte, func(string, bool)) (string, error) {
|
||||
return "", ErrStreamingUnsupported
|
||||
}
|
||||
|
||||
// ErrStreamingUnsupported reports that a batch-only Transcriber cannot stream;
|
||||
// the caller falls back to the batch pipeline.
|
||||
var ErrStreamingUnsupported = errors.New("this transcription provider does not support streaming")
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user