Compare commits

..
Author SHA1 Message Date
Gnanam cd7c2736dc fix: stream Codex reasoning + drop swarm agents on their own completion
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an
'agents never disappear' report.

A) Codex Responses path streamed no reasoning, so a long thinking phase produced
   zero visible output and read as a hang (the activity clock never advanced).
   Root cause: the request never asked for a reasoning summary and the parser had
   no case for reasoning deltas. Now request reasoning.summary="auto" and forward
   response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI
   handler renders it live and refreshes the activity clock.

B) A finished swarm member stayed in the AGENTS panel until the whole turn ended.
   Now it drops once ITS OWN task completes (fade over the linger window, then
   remove), independent of the overall run — matching how finished specialists
   already behave. Running members and mid-flight collect are unaffected.
2026-06-30 21:04:23 +05:30
243 changed files with 1115 additions and 18558 deletions

No files matched your search

+7 -97
View File
@@ -54,10 +54,8 @@ jobs:
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
# and the npm postinstall all build a download URL that 404s. Fail fast so a
# forgotten version bump can never publish mis-versioned, un-installable assets.
# Matches on the ref (not event_name) because release-please dispatches this
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
- name: Check tag matches package.json version
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
shell: bash
run: |
pkg_version="$(node -p "require('./package.json').version")"
@@ -80,15 +78,13 @@ jobs:
path: dist/release/*
if-no-files-found: error
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
# release-please workflow), publish the packaged archive + .sha256 as GitHub
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
# action dependency); each matrix OS creates the release if absent
# (best-effort — release-please normally creates it first) then uploads its
# own assets with --clobber. Tags come from merging the release-please PR.
# On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release
# assets so the documented `scripts/install.sh` / `install.ps1` path (which reads
# /releases/latest) works. Uses the preinstalled `gh` (no new action dependency);
# each matrix OS creates the release if absent (best-effort) then uploads its own
# assets with --clobber. NOTE: maintainers must push the tag — this never tags.
- name: Publish to GitHub Release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -96,89 +92,3 @@ jobs:
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
# assets are live — the package's postinstall downloads its binary from the
# GitHub Release, so publishing before the assets exist would ship a package
# that fails to install.
#
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
# npmjs.com settings must list this repo + workflow file as a trusted
# publisher, and provenance is then generated automatically. Note npm only
# allows configuring a trusted publisher on an EXISTING package, so the very
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
publish-npm:
name: Publish npm package
needs: package
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
# Manual approval gate: this environment carries a required-reviewer rule
# (Settings -> Environments -> npm-publish), so the job pauses until a
# maintainer approves it in the Actions UI before anything reaches npm.
environment:
name: npm-publish
url: https://www.npmjs.com/package/@gitlawb/zero
permissions:
contents: read
id-token: write # OIDC token exchange for npm trusted publishing + provenance
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# No registry-url here on purpose: setup-node's registry-url writes an
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
# unset env var. Trusted publishing needs no token at all.
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
# npm@latest from the registry inside the publish path — an unpinned
# install there would be avoidable supply-chain exposure. Fail fast if a
# future runner/toolchain change ever regresses the bundled npm.
- name: Check npm supports trusted publishing
run: |
npm --version | node -e '
const version = require("fs").readFileSync(0, "utf8").trim();
const [major, minor, patch] = version.split(".").map(Number);
const ok =
major > 11 ||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
if (!ok) {
console.error(
`::error::bundled npm ${version} is too old for OIDC trusted ` +
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
);
process.exit(1);
}
console.log(`npm ${version} supports trusted publishing.`);
'
# Probe the exact URLs postinstall will build (internal/release/release.go
# asset-name scheme) as an anonymous client. This also blocks publishing
# while the repo is still private — private release assets 404 without
# auth, exactly as they would for an installing user.
- name: Verify release assets are downloadable
shell: bash
run: |
version="$(node -p "require('./package.json').version")"
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
for asset in \
"zero-v${version}-linux-x64.tar.gz" \
"zero-v${version}-linux-arm64.tar.gz" \
"zero-v${version}-macos-x64.tar.gz" \
"zero-v${version}-macos-arm64.tar.gz" \
"zero-v${version}-windows-x64.zip"; do
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
if ! curl -fsSLI -o /dev/null "$url"; then
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
exit 1
fi
done
done
echo "all release assets for v${version} are publicly downloadable."
- name: Publish to npm
run: npm publish --access public
-35
View File
@@ -1,35 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write # push the release-PR branch, create the tag + GitHub Release
pull-requests: write # open/update the release PR
issues: write # create/apply release-tracking labels on the release PR
actions: write # dispatch release-artifacts.yml once a release is cut
jobs:
release-please:
name: Release PR / tag
runs-on: ubuntu-latest
steps:
- name: Run release-please
id: release
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
# workflows (GitHub suppresses workflows triggered by workflow-created
# events), so release-artifacts.yml would not run for a release-please
# tag. workflow_dispatch invoked through the API is exempt from that
# suppression — dispatch the artifacts build at the new tag explicitly.
- name: Build and publish release artifacts
if: ${{ steps.release.outputs.release_created }}
env:
GH_TOKEN: ${{ github.token }}
run: |
gh workflow run release-artifacts.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "${{ steps.release.outputs.tag_name }}"
+1 -17
View File
@@ -4,14 +4,12 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -31,8 +29,7 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
doc = yaml.safe_load(handle)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -68,19 +65,6 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
-1
View File
@@ -1 +0,0 @@
{".":"0.1.0"}
+1 -1
View File
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
+1 -21
View File
@@ -6,32 +6,12 @@ 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.1.0 (2026-07-02)
### Features
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
### Bug Fixes
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
## [Unreleased]
### Added
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
GitHub issue/PR templates.
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
and applies on select (Esc reverts).
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
`ZERO_THEME` env var existed).
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
and `ZERO_NO_FADE`.
+1 -1
View File
@@ -1,4 +1,4 @@
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
+3 -37
View File
@@ -8,8 +8,6 @@
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<strong>English</strong> | <a href="README_ZH.md">中文</a>
</p>
Zero is an AI coding agent for your local terminal. It can inspect a repository,
@@ -53,31 +51,6 @@ The npm package installs a small wrapper plus the matching Zero binary for your
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
arm64.
### Bun
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
### Install scripts
Linux/macOS:
@@ -148,13 +121,6 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
zero providers setup longcat --set-active
```
For local models, run Ollama or LM Studio and then use `zero setup` or
@@ -288,9 +254,9 @@ zero update check for newer releases
| Control | Effect |
|---|---|
| `NO_COLOR=<anything>` | disables color output |
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
| `/theme auto|dark|light` | switches theme inside the TUI |
| `ZERO_NO_FADE=1` | disables streaming fade animation |
Meaning does not rely on color alone; diffs, permissions, and statuses also use
-297
View File
@@ -1,297 +0,0 @@
<p align="center">
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
</p>
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<br>
<a href="README.md">English</a> | <strong>中文</strong>
</p>
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
```bash
zero
zero exec "修复 ./pkg 中失败的测试"
zero exec --output-format stream-json < turns.jsonl
```
## 为什么选择 Zero
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
## 安装
### npm
```bash
npm install -g @gitlawb/zero
zero
```
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
### Bun
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 项目安装
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
### 安装脚本
Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
```
Windows PowerShell:
```powershell
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
```
### 从源码构建
源码构建需要 Go 1.25+。
```bash
git clone https://github.com/Gitlawb/zero.git
cd zero
go run ./cmd/zero
```
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
```bash
go build -o zero ./cmd/zero
```
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
```bash
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
```
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
## 首次运行
启动 TUI:
```bash
zero
```
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
```bash
zero setup
zero providers list
zero models list
zero doctor
```
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
```bash
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export LONGCAT_API_KEY=...
```
要直接配置美团 LongCat(LongCat-2.0),运行:
```bash
zero providers setup longcat --set-active
```
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
## 日常使用
### 交互式 TUI
```bash
zero
```
常用控制:
| 控制 | 操作 |
|---|---|
| `Enter` | 发送提示 |
| `/` | 打开斜杠命令建议 |
| `Shift+Tab` | 切换权限模式 |
| `Ctrl+B` | 显示/隐藏侧边栏 |
| `Ctrl+C` | 取消或退出 |
常用斜杠命令:
| 命令 | 用途 |
|---|---|
| `/model`、`/provider` | 切换活动模型/提供商 |
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
| `/image` | 为视觉模型附加图片 |
| `/resume`、`/rewind` | 继续或回滚本地会话 |
| `/compact`、`/context` | 管理上下文使用 |
| `/permissions`、`/tools` | 检查可用工具和策略 |
| `/add-dir` | 为此会话授予额外的写入目录 |
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
### 无头 `exec` 模式
```bash
zero exec "解释 internal/agent/loop.go"
zero exec --model claude-sonnet-4.5 "重构配置加载器"
zero exec --use-spec "为 API 客户端添加速率限制"
zero exec --worktree "在隔离的工作树中尝试迁移"
zero exec --resume
zero exec --fork <session-id> "尝试另一种方法"
```
编程使用:
```bash
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
```
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
## 安全模型
Zero 旨在使副作用可见。
- 工作区读取默认允许。
- 文件写入限制在工作区内,除非你授予其他目录。
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
- 不安全/自主模式是显式选择加入。
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
示例:
```bash
zero --add-dir ../docs-site
zero exec --add-dir ../shared "更新两个仓库"
```
沙箱行为可以通过以下方式检查:
```bash
zero sandbox policy
zero sandbox grants list
```
## Web 和本地控制
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
## 常用命令
```text
zero 交互式 TUI
zero exec 一次性或脚本化智能体运行
zero setup 首次运行提供商设置
zero auth 支持提供商的 OAuth/登录辅助
zero models 模型注册表和能力
zero providers 提供商配置和检测
zero doctor 设置、密钥和连接检查
zero context 上下文预算报告
zero repo-map 确定性仓库映射
zero repo-info 本地仓库摘要
zero search | find 搜索本地会话历史
zero sessions 检查、恢复、分叉和回滚会话
zero spec 管理规范模式草稿
zero specialist 管理专业子智能体
zero skills 管理 Markdown 指令技能
zero plugins 管理插件
zero hooks 管理生命周期钩子
zero mcp 管理 MCP 服务器和工具
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
zero sandbox 检查沙箱策略和授权
zero worktrees 准备隔离的 Git 工作树
zero verify 检测和运行本地验证检查
zero changes 检查和提交本地 Git 变更
zero usage Token 使用量和估算成本
zero cron 定时智能体任务
zero update 检查更新版本
```
## 外观和无障碍
| 控制 | 效果 |
|---|---|
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
## 开发
```bash
go test ./...
go run ./cmd/zero-release build
go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
交叉编译示例:
```bash
go run ./cmd/zero-release build --goos linux --goarch amd64
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
```
## 文档
- [安装](docs/INSTALL.md)
- [更新流程](docs/UPDATE.md)
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
- [专家](docs/SPECIALISTS.md)
- [GitHub Action](docs/GITHUB_ACTION.md)
- [基准测试](docs/BENCHMARK.md)
- [性能](docs/PERFORMANCE.md)
- [智能体评估](docs/AGENT_EVALS.md)
## 贡献
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
安全报告应遵循 [SECURITY.md](SECURITY.md)。
## 许可证
Zero 基于 [MIT 许可证](LICENSE) 发布。
+5 -4
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported only for the ZERO step and never written to the log.
it is exported into the step environment and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported only for the ZERO step. Leave the action
api-key, that variable is exported for the run. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -192,9 +192,10 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
+1 -21
View File
@@ -54,28 +54,8 @@ const nativePath = join(packageRoot, zeroBinaryName());
const localControlHelpers = localControlHelperManifest(packageRoot);
if (!existsSync(nativePath)) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
console.error(
'[zero] No native binary found next to the npm wrapper.\n' +
'The platform binary is fetched at install time by a postinstall script,\n' +
'which did not run (or was skipped) for this install.\n' +
'\n' +
'Fix it now by running the installer manually:\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
'(go run ./cmd/zero, requires Go 1.25+).',
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
);
process.exit(1);
}
+7 -9
View File
@@ -41,9 +41,8 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
## Inputs
@@ -52,8 +51,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -163,10 +162,9 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
-45
View File
@@ -24,51 +24,6 @@ Requirements:
- Node.js 18+
- network access to npm and GitHub Releases
## Bun
Bun is "default-secure" and does not run lifecycle scripts of installed
dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
`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:
@@ -1,146 +0,0 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
+4 -28
View File
@@ -21,17 +21,12 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
@@ -285,25 +280,6 @@ func isContextLimitError(message string) bool {
return false
}
// isStreamTimeoutError reports whether a streamed error is a provider stream
// idle/stall timeout (providerio surfaces these as "idle timeout after …" /
// "no output for …" / "stream stalled"). Such a turn produced nothing, so when
// no content was forwarded yet it can be safely re-issued on a fresh connection
// (see the stall-retry in the agent loop). Substring + case-insensitive to
// tolerate the prefixed "provider stream error: …" wrapping.
func isStreamTimeoutError(message string) bool {
lowered := strings.ToLower(strings.TrimSpace(message))
if lowered == "" {
return false
}
for _, needle := range []string{"idle timeout after", "no output for", "stream stalled"} {
if strings.Contains(lowered, needle) {
return true
}
}
return false
}
// compactionState carries the per-run state the agent loop needs to compact a
// conversation safely. It is created once per Run and is a no-op whenever
// options.ContextWindow <= 0.
-99
View File
@@ -1,99 +0,0 @@
package agent
import "testing"
// decodeToolArguments must (a) decode a normal single-object payload unchanged,
// (b) tolerate a weak model packing multiple concatenated top-level JSON objects
// into one arguments string by decoding the FIRST object and ignoring the rest
// (the minimax-m3 "invalid character '{' after top-level value" failure), and
// (c) still reject a genuinely malformed/truncated first object.
func TestDecodeToolArguments(t *testing.T) {
t.Run("single object decodes normally", func(t *testing.T) {
var a map[string]any
if err := decodeToolArguments(`{"path":"x"}`, &a); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if a["path"] != "x" {
t.Fatalf("decoded %v, want path=x", a)
}
})
t.Run("concatenated objects decode the first, ignore trailing", func(t *testing.T) {
var b map[string]any
if err := decodeToolArguments(`{"path":"first"}{"path":"second"}`, &b); err != nil {
t.Fatalf("multi-object should decode the first, got err = %v", err)
}
if b["path"] != "first" {
t.Fatalf("decoded %v, want the first object (path=first)", b)
}
})
t.Run("distinct tools' args concatenated (real minimax case): first object used", func(t *testing.T) {
var c map[string]any
if err := decodeToolArguments(`{"plan":[1,2]}{"cmd":"go version"}`, &c); err != nil {
t.Fatalf("err = %v, want nil", err)
}
if _, ok := c["plan"]; !ok || c["cmd"] != nil {
t.Fatalf("decoded %v, want only the first object's keys", c)
}
})
t.Run("truncated first object still errors", func(t *testing.T) {
var d map[string]any
if err := decodeToolArguments(`{"path":`, &d); err == nil {
t.Fatal("truncated JSON must still error")
}
})
t.Run("non-JSON trailing garbage still errors (not masked)", func(t *testing.T) {
var g map[string]any
if err := decodeToolArguments(`{"x":1}xyz`, &g); err == nil {
t.Fatal("a valid first object followed by non-JSON garbage must still error")
}
})
t.Run("recoverableToolArguments: only whole-JSON trailing is tolerated", func(t *testing.T) {
cases := []struct {
in string
ok bool
first string
}{
{`{"a":1}`, true, `{"a":1}`},
{`{"a":1}{"b":2}`, true, `{"a":1}`},
{`{"a":1} {"b":2} `, true, `{"a":1}`},
{`{"a":1}xyz`, false, ""},
{`{"a":`, false, ""},
{` `, false, ""},
}
for _, c := range cases {
first, ok := recoverableToolArguments(c.in)
if ok != c.ok || (ok && first != c.first) {
t.Fatalf("recoverableToolArguments(%q) = (%q,%v), want (%q,%v)", c.in, first, ok, c.first, c.ok)
}
}
})
t.Run("historySafeToolCalls recovers the first object, not {}", func(t *testing.T) {
out := historySafeToolCalls([]ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{"path":"README.md"}{"path":"AGENTS.md"}`}, // concatenated -> first
{ID: "c2", Name: "read_file", Arguments: `{"path":"x"}xyz`}, // garbage -> {}
{ID: "c3", Name: "read_file", Arguments: `{"path":"ok"}`}, // valid -> unchanged
})
if out[0].Arguments != `{"path":"README.md"}` {
t.Fatalf("concatenated args should record the first object, got %q", out[0].Arguments)
}
if out[1].Arguments != "{}" {
t.Fatalf("garbage-trailing args should record {}, got %q", out[1].Arguments)
}
if out[2].Arguments != `{"path":"ok"}` {
t.Fatalf("valid args should be unchanged, got %q", out[2].Arguments)
}
})
t.Run("whitespace-only errors (caller guards empty)", func(t *testing.T) {
var e map[string]any
if err := decodeToolArguments(" ", &e); err == nil {
t.Fatal("whitespace-only must error")
}
})
}
-44
View File
@@ -1,44 +0,0 @@
package agent
import (
"context"
"os"
"time"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
// diagnostics for the model. Warnings and hints are excluded — nagging about
// style on every edit is noise, while a type error the edit just introduced is
// exactly what the model should see before its next step. Returns nil when
// manager is nil, disabling inline diagnostics entirely.
func NewFileDiagnostics(manager *lsp.Manager) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
if err != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
if err != nil {
return ""
}
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
if len(errors) == 0 {
return ""
}
return lsp.FormatDiagnostics(absPath, errors)
}
}
+93 -308
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
@@ -21,12 +20,6 @@ import (
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
const (
toolResultMetaControl = "control"
toolResultControlSpecReview = "spec_review_required"
@@ -210,74 +203,55 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
// the only output whose re-stream on a stall retry would visibly DUPLICATE
// answer text the user already read. Transient working indicators
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
// counted: a turn that streamed only those then stalled — the common
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
// the TUI resets the tool-call preview on the next tool-call-start and a
// stalled turn is never appended to `messages` (it returns on the error
// before the append), so the retry re-sends clean context with no
// conversation-state duplication.
forwardedVisibleText := false
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
if options.OnText != nil {
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
}
if options.OnReasoning != nil {
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
}
if options.OnToolCallStart != nil {
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
}
if options.OnToolCallDelta != nil {
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
}
// recoverStreamError applies the same non-stall recovery the initial stream
// gets to ANY collected error — including one from a reissued (stall-retry)
// stream: an image rejection gets the friendly wrapping, and a context limit
// gets one compaction + reactive reissue (omitting visible callbacks, since
// any pre-error output was already forwarded). It returns the possibly-updated
// collected and a non-nil stop error when the run must end now.
recoverStreamError := func(collected zeroruntime.CollectedStream) (zeroruntime.CollectedStream, error) {
if isImageRejectionError(errors.New(collected.Error)) {
return collected, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, zeroruntime.CollectOptions{
OnText: options.OnText,
OnReasoning: options.OnReasoning,
OnUsage: options.OnUsage,
OnToolCallStart: options.OnToolCallStart,
OnToolCallDelta: options.OnToolCallDelta,
})
if collected.Error != "" {
collectedErr := errors.New(collected.Error)
if isImageRejectionError(collectedErr) {
result.Messages = copyMessages(messages)
return result, fmt.Errorf("model %s rejected the image: %s. The model may not support image input — try switching to a vision-capable model (claude, gpt-4o, gemini)", options.Model, collected.Error)
}
// REACTIVE compaction: the streamed error may also be a context limit
// (some providers surface it mid-stream). Compact and retry once.
// REACTIVE compaction: the streamed error may also be a context
// limit (some providers surface it mid-stream). Compact and retry
// the same turn once before giving up.
if compacted, retried, retryErr := compactor.recover(ctx, provider, messages, request.Tools, collected.Error); retried {
messages = compacted
if retryErr != nil {
return collected, retryErr
result.Messages = copyMessages(messages)
return result, retryErr
}
// Reuse the SAME active-mode partition (exposed) from this turn rather
// than the bare toolDefinitions: exposed depends on registry+loaded (not
// the messages), so it stays valid after compaction.
// Reuse the SAME active-mode partition (exposed) from this
// turn rather than the bare toolDefinitions: exposed depends on
// registry+loaded (not the messages), so they stay valid after compaction.
// Routing through an empty-loaded partition here would re-hide every
// already-loaded deferred tool.
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
// Pre-content reconnect of a mid-stream disconnect: route the connect
// through the reconnect helper too (AUDIT-L1).
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
return collected, retryStreamErr
result.Messages = copyMessages(messages)
return result, retryStreamErr
}
// Omit OnText on the reactive retry: when the original error
// surfaced MID-stream, partial text was already forwarded to the
// user. Re-streaming the retried response on top of it would
// duplicate output. OnUsage IS kept so token telemetry/budgeting
// still counts the successful retry. The retried text is captured
// in collected.Text and becomes the turn's assistant message.
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, zeroruntime.CollectOptions{
OnUsage: options.OnUsage,
})
}
return collected, nil
}
collected := zeroruntime.CollectStreamWithOptions(ctx, stream, forwardingOpts)
if collected.Error != "" {
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
}
// Check ctx first: on cancellation helpers.go sets collected.Error to
// ctx.Err().Error(), so returning errors.New(collected.Error) would lose
@@ -286,58 +260,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
result.Messages = copyMessages(messages)
return result, ctx.Err()
}
// A stream idle/stall timeout is safely re-issued when the turn committed NO
// answer text — no forwarded visible prose (forwardedVisibleText) and no
// collected final text (collected.Text). This covers two cases:
// 1. Nothing streamed at all before the connection died (the original
// macOS stale-pooled-connection hang past the response-header timeout).
// 2. The model streamed transient reasoning and began a tool call (e.g. a
// large write_file) then froze mid-arguments — the common gpt-5.x /
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
// (a turn with collected.Error returns before dispatch below) and NEVER
// appended to `messages`, so a retry re-issues clean context; the only
// re-render is transient previews, not duplicated answer text. This is
// why the gate no longer excludes collected.ToolCalls: an incomplete
// tool call from a timed-out stream is discard-and-retry, not output.
// A turn that forwarded real prose is NOT retried (it would duplicate visible
// answer text) and falls through to the error return below. Capped +
// exponential backoff, with a user-visible notice per attempt.
for attempt := 1; attempt <= maxStreamStallRetries &&
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
collected.Text == ""; attempt++ {
if notify := stallRetryNoticeFor(options); notify != nil {
notify(attempt, maxStreamStallRetries)
}
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
result.Messages = copyMessages(messages)
return result, err
}
retryRequest := zeroruntime.CompletionRequest{
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
result.Messages = copyMessages(messages)
return result, retryErr
}
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
}
if collected.Error != "" {
// Route a reissued stream's non-stall error through the SAME recovery as
// the initial stream (image-rejection wrapping / context-limit compaction)
// rather than returning it raw.
updated, stop := recoverStreamError(collected)
collected = updated
if stop != nil {
result.Messages = copyMessages(messages)
return result, stop
}
if collected.Error != "" {
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
result.Messages = copyMessages(messages)
return result, errors.New(collected.Error)
}
// Carry the turn's terminal stop reason so a final answer cut off at the
@@ -475,35 +400,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
precomputedStart, precomputedEnd := 0, 0
for index, call := range collected.ToolCalls {
// When this call starts a consecutive run of >= 2 auto-allowed read-only
// calls, execute the whole run concurrently now (see parallel_tools.go).
// The scan happens lazily at the run's first index — never ahead of a
// pending mutating call — so read-after-write ordering is preserved.
if index >= precomputedEnd {
runEnd := index
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
runEnd++
}
if runEnd-index >= 2 {
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
precomputedStart, precomputedEnd = index, runEnd
}
}
if options.OnToolCall != nil {
options.OnToolCall(call)
}
var toolResult ToolResult
var abortErr error
if index >= precomputedStart && index < precomputedEnd {
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
} else {
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
}
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -714,20 +615,8 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
for i, call := range calls {
safe[i] = call
args := strings.TrimSpace(call.Arguments)
switch {
case args == "":
if args == "" || !json.Valid([]byte(args)) {
safe[i].Arguments = "{}"
case json.Valid([]byte(args)):
// already a single valid JSON value — keep as-is
default:
// Recover the first object for the concatenated case so the replayed
// transcript matches what executeToolCall actually ran; otherwise (real
// corruption) fall back to an empty object.
if first, ok := recoverableToolArguments(args); ok {
safe[i].Arguments = first
} else {
safe[i].Arguments = "{}"
}
}
}
return safe
@@ -752,53 +641,10 @@ func toolSchemaJSON(registry *tools.Registry, name string) string {
// is a non-nil ABORT error only when the call demands the whole run stop (a
// canceled/timed-out ask_user prompt) rather than continuing — every ordinary
// success or tool error returns a nil abort error.
// recoverableToolArguments inspects a tool call's raw JSON arguments. It returns
// the raw text of the FIRST JSON value when the payload is one value optionally
// followed by additional WHOLE JSON values (the weak-model concatenated case, e.g.
// `{A}{B}`). ok is false when the payload is genuinely malformed: a bad/truncated
// first value, OR trailing NON-JSON garbage after a valid first value (e.g.
// `{"x":1}xyz`) — that case must still error, not be silently accepted. Sharing
// this between dispatch and replay-history keeps them consistent: only a cleanly
// recoverable first object is ever used.
func recoverableToolArguments(arguments string) (first string, ok bool) {
dec := json.NewDecoder(strings.NewReader(arguments))
var head json.RawMessage
if err := dec.Decode(&head); err != nil {
return "", false
}
// The remainder must be only whole JSON values (or nothing); any decode error
// other than EOF means trailing garbage — reject so corruption still surfaces.
for {
var rest json.RawMessage
if err := dec.Decode(&rest); err != nil {
if errors.Is(err, io.EOF) {
return strings.TrimSpace(string(head)), true
}
return "", false
}
}
}
// decodeToolArguments decodes a tool call's JSON arguments into v. It tolerates a
// weaker model that packs MULTIPLE concatenated top-level JSON objects into one
// arguments string (`{A}{B}`) — the cause of "invalid character '{' after top-level
// value" failures with small models like minimax-m3 — by decoding the FIRST object
// and ignoring the trailing WHOLE JSON values, so the primary intended call runs.
// A genuinely malformed payload (bad/truncated first object, or non-JSON trailing
// garbage) still returns the real parse error; a standard single-object payload
// decodes exactly as before.
func decodeToolArguments(arguments string, v any) error {
if first, ok := recoverableToolArguments(arguments); ok {
return json.Unmarshal([]byte(first), v)
}
// Not cleanly recoverable — surface the genuine parse error.
return json.Unmarshal([]byte(arguments), v)
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
@@ -840,25 +686,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
}
// A shell command that sets sandbox_permissions: with_additional_permissions
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
// is the single source of truth for that shape, and buildPermissionEvent (below)
// calls it again to render the prompt's scope text. Check it here, before any
// permission prompt is shown: a malformed payload can never be satisfied no
// matter what the user decides, so surfacing it as a plain tool error (which the
// model can see and retry) is both clearer and avoids presenting a normal-looking
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
if toolFound && isShellCommandTool(call.Name) {
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: " + err.Error(),
}, nil
}
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
@@ -1075,8 +902,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
@@ -2204,18 +2029,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
}
}
// A command that explicitly requests additional sandbox permissions
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
// must consent to, even when the base command's sandbox decision was Allow.
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
// so the event MUST be a prompt — otherwise it carried Action=allow while the
// loop waited on a decision, and a UI that renders only prompts (the TUI)
// dropped it, deadlocking the run. Keep this consistent with
// shouldRequestPermission's own condition.
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
action = PermissionActionPrompt
}
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
return PermissionEvent{}, false
}
@@ -2367,12 +2180,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2387,9 +2195,7 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2522,97 +2328,76 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
// deferral there is no mid-session loading, so this is byte-stable across turns
// and byte-identical to the pre-deferral output.
if !active {
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
for _, tool := range visible {
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, runtimeToolDefinition(tool))
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, ""
}
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
// deferred tool loads mid-session. The tools block is part of the provider's
// cached prefix; if a load reorders it, the cache invalidates from that point
// through the system prompt and messages — on EVERY load. Previously the whole
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
// middle and shifted every later definition. Instead we build three append-only
// regions:
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
if loaded[name] {
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
} else {
hiddenTools = append(hiddenTools, tool)
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
eager = append(eager, runtimeToolDefinition(tool))
}
sort.Slice(eager, func(left int, right int) bool {
return eager[left].Name < eager[right].Name
})
sort.Slice(loadedTail, func(left int, right int) bool {
return loadedTail[left].Name < loadedTail[right].Name
})
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if len(hiddenTools) > 0 {
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
definitions = append(definitions, eager...)
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
definitions = append(definitions, loadedTail...)
return definitions, discovery
}
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
+2 -127
View File
@@ -37,35 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
return ch, nil
}
type recordingWebSearchTool struct {
calls []map[string]any
}
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"query": {Type: "string"},
},
Required: []string{"query"},
AdditionalProperties: false,
}
}
func (tool *recordingWebSearchTool) Safety() tools.Safety {
return tools.Safety{
SideEffect: tools.SideEffectNetwork,
Permission: tools.PermissionPrompt,
Reason: "Sends model-provided search query text to the configured web search backend.",
AdvertiseInAuto: true,
}
}
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
tool.calls = append(tool.calls, cloneArgs(args))
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
}
type sandboxDeniedRetryTool struct {
calls []map[string]any
}
@@ -894,7 +865,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
}
}
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
registry := tools.NewRegistry()
for _, tool := range tools.CoreNetworkTools() {
@@ -925,52 +896,6 @@ func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
}
}
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
search := &recordingWebSearchTool{}
registry := tools.NewRegistry()
registry.Register(search)
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
var requests []PermissionRequest
result, err := Run(context.Background(), "search", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
requests = append(requests, request)
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
}
if len(requests) != 1 {
t.Fatalf("expected one permission request, got %#v", requests)
}
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
t.Fatalf("unexpected permission request: %#v", requests[0])
}
if len(search.calls) != 0 {
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
}
}
func TestRunFiltersAdvertisedTools(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
@@ -1128,10 +1053,7 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
// Genuinely malformed (truncated) args: still error -> sanitize -> retry.
// (Concatenated multi-object args are now tolerated; see
// TestRunRecoversFirstObjectFromConcatenatedToolArgs.)
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"`},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
@@ -1181,53 +1103,6 @@ func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
}
}
// A weak model (e.g. minimax-m3) that packs MULTIPLE concatenated JSON objects into
// one tool-call slot must RECOVER the first object and run the call, not fail with
// "invalid character '{' after top-level value". The trailing object(s) are dropped
// (the model re-issues them on a later turn if still needed).
func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hello readme"), 0o644); err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"README.md"}{"path":"AGENTS.md"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
},
{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
},
}
result, err := Run(context.Background(), "read", provider, Options{Registry: registry, MaxTurns: 2})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
}
var toolResult string
for _, message := range provider.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolResult = message.Content
}
}
if strings.Contains(toolResult, "Failed to parse arguments") {
t.Fatalf("concatenated args should recover the first object, not fail to parse: %q", toolResult)
}
if !strings.Contains(toolResult, "hello readme") {
t.Fatalf("expected README.md contents (the first object's path), got %q", toolResult)
}
}
func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
// A single assistant turn with two tool calls where the first mutates and
// self-correct fails must keep both tool_results contiguous, with the feedback
-85
View File
@@ -1,85 +0,0 @@
package agent
import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// auto-allowed read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
// precomputedToolResult is one parallel read-ahead execution, keyed back to
// its batch index by the caller.
type precomputedToolResult struct {
result ToolResult
abortErr error
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
// auto-allowed for these args, so no interactive prompt or workspace mutation
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
// and tool_search (mutates the deferred-tool set) stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
return false
}
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return false
}
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. Any
// permission prompt that still occurs (a sandbox preflight can demand one even
// for an auto-allowed read) is serialized so an interactive front-end never
// sees two prompts at once.
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
if options.OnPermissionRequest != nil {
var promptMutex sync.Mutex
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptMutex.Lock()
defer promptMutex.Unlock()
return inner(ctx, request)
}
}
results := make([]precomputedToolResult, end-start)
semaphore := make(chan struct{}, maxParallelReadTools)
var waitGroup sync.WaitGroup
for index := start; index < end; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
}(index)
}
waitGroup.Wait()
return results
}
-184
View File
@@ -1,184 +0,0 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
// actually ran concurrently.
type probeTool struct {
name string
sideEffect tools.SideEffect
delay time.Duration
mu sync.Mutex
active int
maxActive int
log []string
}
func (tool *probeTool) Name() string { return tool.name }
func (tool *probeTool) Description() string { return "test probe tool" }
func (tool *probeTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
AdditionalProperties: false,
}
}
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
tool.active++
if tool.active > tool.maxActive {
tool.maxActive = tool.active
}
tool.log = append(tool.log, "start:"+id)
tool.mu.Unlock()
time.Sleep(tool.delay)
tool.mu.Lock()
tool.active--
tool.log = append(tool.log, "end:"+id)
tool.mu.Unlock()
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
}
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
}
}
func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
t.Fatal("auto-allowed read tool must be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
t.Fatal("mutating tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
t.Fatal("unknown tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
t.Fatal("undecodable arguments must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
registry := tools.NewRegistry()
registry.Register(probe)
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
var results []ToolResult
_, err := Run(context.Background(), "probe", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { results = append(results, result) },
})
if err != nil {
t.Fatal(err)
}
if probe.maxActive < 2 {
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
}
// Results must still be recorded in original call order.
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
t.Fatalf("tool results out of order: %#v", results)
}
messages := provider.requests[1].Messages
var toolOrder []string
for _, message := range messages {
if message.Role == zeroruntime.MessageRoleTool {
toolOrder = append(toolOrder, message.ToolCallID)
}
}
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
}
}
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite}
// Shared log across both probes so relative ordering is observable.
write.mu = sync.Mutex{}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
// The write must start only after r1+r2 finished, and r3/r4 must start only
// after the write finished: batches never cross a mutating call.
index := func(log []string, entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
return -1
}
readLog := func() []string { read.mu.Lock(); defer read.mu.Unlock(); return append([]string(nil), read.log...) }()
writeLog := func() []string { write.mu.Lock(); defer write.mu.Unlock(); return append([]string(nil), write.log...) }()
if index(writeLog, "start:w") == -1 {
t.Fatalf("write probe never ran: %v", writeLog)
}
for _, entry := range []string{"end:r1", "end:r2"} {
if index(readLog, entry) == -1 {
t.Fatalf("first read batch incomplete: %v", readLog)
}
}
// r3/r4 must appear strictly after r1/r2 in the read log (the write barrier
// between the two batches forces full separation).
firstBatchMaxEnd := max(index(readLog, "end:r1"), index(readLog, "end:r2"))
secondBatchMinStart := min(index(readLog, "start:r3"), index(readLog, "start:r4"))
if secondBatchMinStart < firstBatchMaxEnd {
t.Fatalf("second read batch started before first batch ended: %v", readLog)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -1,44 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if !(readPos < alphaPos && searchPos < alphaPos) {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
@@ -1,70 +0,0 @@
package agent
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
// standing in for bash so buildPermissionEvent can be exercised directly.
type promptShellTool struct{}
func (promptShellTool) Name() string { return "bash" }
func (promptShellTool) Description() string { return "run a shell command" }
func (promptShellTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (promptShellTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
}
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// A command whose sandbox decision is Allow but that explicitly requests
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
// prompt, never Action=allow. Previously it carried allow while the loop still
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
// folder-creation shape captured in the reported hang.
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
args := map[string]any{
"command": "mkdir foo && cd foo && go mod init foo",
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
}
// Base command allowed by the sandbox…
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if !ok {
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
}
if event.Action != PermissionActionPrompt {
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
}
// Sanity: shouldRequestPermission agrees the loop blocks on this.
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
}
}
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
// prompt (shouldRequestPermission is false; it just runs) — the override must
// not over-reach and prompt for ordinary allowed commands.
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
args := map[string]any{"command": "ls"}
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
if event.Action == PermissionActionPrompt {
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
}
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
}
}
-51
View File
@@ -1,51 +0,0 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
-15
View File
@@ -40,21 +40,6 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
}
}
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
// stream that connected and produced only transient output, then went silent).
// Distinct wording from reconnectNoticeFor's "connection lost" because the
// connection was fine — the model stalled. Surfaced through OnReasoning, the
// non-content channel, so it never folds into the answer text. Nil when there
// is no reasoning sink (the retry still happens silently).
func stallRetryNoticeFor(options Options) reconnectNotifier {
if options.OnReasoning == nil {
return nil
}
return func(attempt, max int) {
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
}
}
// streamWithReconnect issues request via provider.StreamCompletion and, on a
// transient disconnect error, retries the connect up to maxStreamReconnects
// times with exponential backoff. It returns the live stream on success, or the
-63
View File
@@ -1,63 +0,0 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
-168
View File
@@ -1,168 +0,0 @@
package agent
import (
"context"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
// timeout error for the first stallBefore calls, then succeeds with "done". The
// pre-stall emission simulates different partial-output shapes:
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
// (re-issuing would duplicate visible answer text).
// - reasoningText: transient reasoning preview — IS retried.
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
// "froze mid-write_file" case — IS retried (the incomplete call is never
// executed or committed).
type stallProvider struct {
calls int32
stallBefore int32
partialText string
reasoningText string
partialToolCall string
}
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
n := atomic.AddInt32(&p.calls, 1)
ch := make(chan zeroruntime.StreamEvent, 5)
if n <= p.stallBefore {
if p.partialText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
}
if p.reasoningText != "" {
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: p.reasoningText}
}
if p.partialToolCall != "" {
// A tool call that starts and streams a partial argument fragment but
// never gets StreamEventToolCallEnd, then the stream errors — exactly
// the gpt-5.x "began the big write_file then froze" shape.
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "tc_1", ToolName: p.partialToolCall}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "tc_1", ArgumentsFragment: `{"path":"x.html","content":"<!doctype`}
}
ch <- zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: "provider stream error: no output for 6m (the model produced nothing)",
}
close(ch)
return ch, nil
}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
// A no-output stall is re-issued on a fresh connection and recovers — this is the
// macOS stale-pooled-connection hang turned into an automatic recovery.
func TestRunRetriesStreamStallWithNoOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1}
result, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err != nil {
t.Fatalf("a no-output stall should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall AFTER partial output must NOT be retried — re-issuing would duplicate
// the already-streamed text. It surfaces the error instead.
func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
p := &stallProvider{stallBefore: 1, partialText: "partial"}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a stall after partial output must NOT be retried; want an error")
}
if got := atomic.LoadInt32(&p.calls); got != 1 {
t.Fatalf("partial-then-stall must not retry, got %d calls", got)
}
}
// A stall after only transient REASONING was forwarded (no prose text, no
// completed tool call) IS retried: reasoning is a "thinking" preview, not
// committed answer text, so re-streaming it on a clearly-signalled retry is
// acceptable and recovers the common "reasoned then froze" stall. Only
// forwarded final prose (OnText) blocks the retry (collected.Text is empty
// here, so this exercises the transient-vs-prose gate specifically).
func TestRunRetriesStallAfterOnlyReasoning(t *testing.T) {
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
})
if err != nil {
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
}
// A stall mid-way through an INCOMPLETE tool call (started + partial args, never
// ended) IS retried — the exact gpt-5.x / ollama "began the big write_file then
// froze" case. The incomplete call is never executed (a turn returns on the
// stall error before dispatch) and never committed to history, so re-issuing is
// safe and recovers to success. This is the primary scenario this change fixes.
func TestRunRetriesStallAfterIncompleteToolCall(t *testing.T) {
starts := 0
p := &stallProvider{stallBefore: 1, reasoningText: "planning", partialToolCall: "write_file"}
result, err := Run(context.Background(), "go", p, Options{
Registry: tools.NewRegistry(),
OnReasoning: func(string) {},
OnToolCallStart: func(string, string) { starts++ },
OnToolCallDelta: func(string, string) {},
})
if err != nil {
t.Fatalf("a stall mid-incomplete-tool-call should retry to success, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q, want %q", result.FinalAnswer, "done")
}
if got := atomic.LoadInt32(&p.calls); got != 2 {
t.Fatalf("want 2 calls (1 stall + 1 retry), got %d", got)
}
if starts != 1 {
t.Fatalf("the incomplete tool call should have been forwarded once before the stall, got %d starts", starts)
}
}
// A persistent stall surfaces an error after exhausting the capped retries.
func TestRunGivesUpAfterMaxStallRetries(t *testing.T) {
p := &stallProvider{stallBefore: 99}
_, err := Run(context.Background(), "go", p, Options{Registry: tools.NewRegistry()})
if err == nil {
t.Fatal("a persistent stall must surface an error after exhausting retries")
}
if got := atomic.LoadInt32(&p.calls); got != int32(1+maxStreamStallRetries) {
t.Fatalf("want %d calls (1 + %d retries), got %d", 1+maxStreamStallRetries, maxStreamStallRetries, got)
}
}
func TestIsStreamTimeoutError(t *testing.T) {
timeouts := []string{
"provider stream error: no output for 10m (the model produced nothing)",
"provider stream error: idle timeout after 5m0s (upstream stopped sending data)",
"stream stalled (upstream kept the connection alive but produced no output)",
}
for _, m := range timeouts {
if !isStreamTimeoutError(m) {
t.Fatalf("want timeout-classified: %q", m)
}
}
notTimeouts := []string{"", "context length exceeded", "rate limit error: slow down", "model not found"}
for _, m := range notTimeouts {
if isStreamTimeoutError(m) {
t.Fatalf("must NOT classify as timeout: %q", m)
}
}
}
+3 -65
View File
@@ -5,7 +5,6 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
@@ -87,9 +86,6 @@ func buildSystemPrompt(options Options) string {
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -168,64 +164,6 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -372,9 +310,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
+9 -7
View File
@@ -46,10 +46,14 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
## Editing discipline
@@ -60,9 +64,7 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
targeted diffs. Match the existing indentation, imports, and idioms.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+12 -10
View File
@@ -6,13 +6,11 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
const (
familyOpenAI = "openai"
@@ -47,10 +45,9 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -73,3 +70,8 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+2 -4
View File
@@ -29,11 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+2 -3
View File
@@ -23,8 +23,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"scaled to the work",
"comment density",
"how each requirement is met",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -129,7 +128,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
-22
View File
@@ -174,16 +174,6 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -199,12 +189,6 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -285,12 +269,6 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
+2 -6
View File
@@ -455,15 +455,11 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
// The timeout is longer than materialization of the small fixture even on
// slower Windows CI runners, so the agent is reached before it blocks until
// the deadline fires. 10s wasn't generous enough in practice — it failed
// on an otherwise-unrelated PR merge (materialization alone consumed the
// full budget on a contended Windows runner, so the agent was never
// reached); widened for headroom, since the assertions below still bound
// worst-case test time via that same deadline, not this constant.
// the deadline fires.
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
TaskID: "document-stream-json-verify-events",
WorkRoot: t.TempDir(),
Timeout: 30 * time.Second,
Timeout: 10 * time.Second,
})
if !agentReached {
+2 -5
View File
@@ -43,11 +43,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
conn := acp.NewConn(deps.stdin, stdout)
acp.NewAgent(conn, acp.Deps{
ResolveConfig: deps.resolveConfig,
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
// so ACP is authenticated for apiKeyStored profiles like every other
// surface — no ACP-specific credential handling needed.
NewProvider: deps.newProvider,
RunAgent: agent.Run,
NewProvider: deps.newProvider,
RunAgent: agent.Run,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
+58 -171
View File
@@ -19,11 +19,9 @@ import (
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/provideronboarding"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -45,44 +43,37 @@ import (
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
// exportActiveProvider pins spawned children to the run's provider (production:
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
// by fillAppDeps, so tests never mutate the process environment unless they
// inject it). nil ⇒ no export.
exportActiveProvider func(providerName string)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -108,10 +99,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
exportActiveProvider: config.SetActiveProviderEnv,
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
@@ -128,19 +118,13 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
// bound must describe the same login (the key is passed on to the Codex
// account-header resolver so it never re-selects independently).
resolver, loginKey := oauthLoginForProfile(profile)
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: resolver,
OAuthLoginKey: loginKey,
OAuthResolver: oauthResolverForProfile(profile),
})
},
probeProviderHealth: providerhealth.Probe,
discoverProviderModels: defaultDiscoverProviderModels,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
probeProviderHealth: providerhealth.Probe,
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
@@ -182,7 +166,6 @@ func defaultAppDeps() appDeps {
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -230,19 +213,13 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
// CLI runs opt into the models.dev overlay (cached live context limits and
// pricing on top of the curated catalog). Explicitly enabled here — and only
// here — so library consumers and hermetic tests are never perturbed by a
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
// --theme <name> selects the TUI palette non-interactively (auto or any registered
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
// --add-dir afterward so it may appear on either side of --theme.
theme, args, err := splitLeadingThemeFlag(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -392,8 +369,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
@@ -416,14 +391,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
}
// First-run users reach for `zero login`/`zero logout` (reported in the
// wild); point them at the real command instead of a bare usage pointer.
switch strings.ToLower(args[0]) {
case "login", "logout":
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
return 1
}
}
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
return 1
}
@@ -454,9 +421,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.discoverProviderModels == nil {
deps.discoverProviderModels = defaults.discoverProviderModels
}
if deps.detectLocalRuntimes == nil {
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
}
@@ -526,23 +490,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
// Wrap newProvider ONCE, after all defaults are filled, so every surface that
// builds the runtime provider gets the credential-store key applied. The
// resolver is pure and leaves apiKeyStored profiles keyless; an unwrapped
// build sends unauthenticated requests. config.ApplyStoredAPIKey is a no-op
// when the key is already set or the profile isn't stored-key, so this is
// safe and idempotent for every profile kind and every injected newProvider.
baseNewProvider := deps.newProvider
userConfigPath := deps.userConfigPath
deps.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return baseNewProvider(applyStoredProviderKeyAt(profile, userConfigPath))
}
return deps
}
@@ -551,11 +501,6 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -563,32 +508,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
// A resolve failure the setup wizard can FIX is not fatal for the
// interactive TUI: drop into the wizard with an empty config so the user
// can onboard/repair, instead of exiting with an error they can only fix
// by hand-editing config.json. That covers a missing/unresolvable active
// provider and an active provider without a model (custom endpoints have
// no catalog default) — previously the second shape bricked bare `zero`
// and `zero setup`, the exact commands that could have fixed it. Any
// other error (malformed JSON, directory conflict, etc.) is still fatal,
// and headless commands (zero config / zero exec) still fail with the
// actionable message.
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
return writeAppError(stderr, err.Error(), 1)
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -721,7 +641,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
@@ -770,11 +689,9 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -802,50 +719,20 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
}
}
// buildProvider constructs the run's provider at STARTUP — it is called only from
// the two launch paths (interactive TUI and headless exec), never from mid-run
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
// (and every other newProvider caller) needs no per-site key handling.
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return nil, err
}
// Pin spawned children (sub-agents / swarm members inherit the environment) to
// THIS run's provider from launch, not only after an in-session switch. Without
// the launch-time export a child re-resolves config.json at spawn time, so a
// provider switch persisted by ANOTHER zero process mid-session would silently
// move new children onto a different provider (and different credentials) than
// the parent is running. Runtime switches (/model, /provider, wizard,
// onboarding) re-export on commit, keeping the pin current. Injected via deps
// (nil in tests) because it mutates the process environment.
if deps.exportActiveProvider != nil {
deps.exportActiveProvider(resolved.Provider.Name)
}
return provider, nil
}
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
// when the resolver left it empty (the resolver is pure — no I/O — so an
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
// providers and when nothing is stored. This is applied once, centrally, by the
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
// and any future caller) is covered without a per-site invariant to forget.
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
if userConfigPath == nil {
return profile
}
if path, perr := userConfigPath(); perr == nil {
// Load the API key from the encrypted credential store when it isn't already
// resolved from inline config or an env var. This keeps the resolver pure (no
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
profile := resolved.Provider
if path, perr := deps.userConfigPath(); perr == nil {
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
return profile
return deps.newProvider(profile)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
@@ -1146,10 +1033,9 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
return addDirs, args, nil
}
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
// from the root argument list and validates it against the registered themes. The
// last occurrence wins. A value outside the allowed set is a loud error rather than
// a silent fallback.
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
// from the root argument list and validates it. The last occurrence wins. A value
// outside the allowed set is a loud error rather than a silent fallback.
func splitLeadingThemeFlag(args []string) (string, []string, error) {
theme := ""
for len(args) > 0 {
@@ -1157,7 +1043,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
switch {
case args[0] == "--theme":
if len(args) < 2 {
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
}
value = strings.TrimSpace(args[1])
args = args[2:]
@@ -1167,10 +1053,12 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
default:
return theme, args, nil
}
if !tui.ValidThemeArg(value) {
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
switch strings.ToLower(value) {
case "auto", "dark", "light":
theme = strings.ToLower(value)
default:
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
}
theme = strings.ToLower(value)
}
return theme, args, nil
}
@@ -1190,8 +1078,7 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1206,7 +1093,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() || addr.IsPrivate()
return addr.IsLoopback()
}
return false
}
+1 -356
View File
@@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -158,141 +157,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
}
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider should not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
usable := config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
}
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
// config.json has providers configured but none marked active (e.g. a
// blank/stale activeProvider field) — Resolve returns the successfully
// normalized providers list alongside ErrNoActiveProvider.
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI must not launch on a non-provider resolve error")
return 0
},
})
if exitCode == 0 {
t.Fatal("exit code = 0, want non-zero for fatal config error")
}
if !strings.Contains(stderr.String(), "invalid config JSON") {
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
}
}
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1328,153 +1192,6 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
@@ -1680,79 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
if launched {
t.Fatal("the TUI must not launch on a bad --theme value")
}
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
}
}
// A custom endpoint saved without a model previously bricked bare `zero` and
// `zero setup` — the exact commands that could have fixed it (the resolve
// error escaped before the wizard could open). The interactive TUI now treats
// the requires-model failure as "needs onboarding", same as a missing active
// provider. The error comes from a REAL Resolve over a real config file, so
// this exercises the production sentinel wrapping, not a hand-built error.
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
brokenConfig := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(brokenConfig, []byte(`{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`), 0o600); err != nil {
t.Fatal(err)
}
launched := false
var launchedOptions tui.Options
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not be called without a resolved provider")
return nil, nil
},
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(_ context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
}
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
}
}
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
// users try them (reported in the wild), so the unknown-command error points at
// the real command.
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
if exitCode != 2 {
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
}
if !strings.Contains(stderr.String(), `"zero auth login"`) {
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
}
// An unrelated unknown command gets no misleading suggestion.
stderr.Reset()
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
if strings.Contains(stderr.String(), "did you mean") {
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
}
}
+3 -22
View File
@@ -283,9 +283,6 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// carries no token material. A real browser launch is intentionally not
// performed (the sandbox/headless contexts make printing the safer default).
OpenBrowser: func(string) error { return nil },
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
AllowPresets: true,
})
}
@@ -301,27 +298,12 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: provider,
Provider: parsed.positional[0],
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -504,9 +486,8 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
-180
View File
@@ -1,180 +0,0 @@
package cli
import (
"bytes"
"errors"
"os"
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// seedStoredProviderKey writes key into a credential store rooted beside the
// returned config path, and returns deps fields wired at that path — the same
// layout applyStoredProviderKey resolves through in production.
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
t.Helper()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
dir := t.TempDir()
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("ProviderKeyStoreAt: %v", err)
}
if err := store.Set(providerName, key); err != nil {
t.Fatalf("seed stored key: %v", err)
}
return filepath.Join(dir, "config.json")
}
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
ucp := func() (string, error) { return configPath, nil }
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
if stored.APIKey != "sk-stored-test" {
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
}
// A profile that did NOT opt into stored-key auth must be left unchanged —
// a stale store entry must not silently reactivate credentials.
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
if plain.APIKey != "" {
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
}
}
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
// escalation switcher) gets the stored key applied. Passing the raw resolved
// profile previously sent unauthenticated requests for apiKeyStored profiles,
// the default onboarding outcome. Testing the wrap covers all those surfaces.
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
var captured config.ProviderProfile
deps := fillAppDeps(appDeps{
userConfigPath: func() (string, error) { return configPath, nil },
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
captured = profile
return nil, nil // only the captured profile matters here
},
})
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
t.Fatalf("wrapped newProvider: %v", err)
}
if captured.APIKey != "sk-stored-wrap" {
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
}
}
// buildProvider (the TUI/exec STARTUP construction site) must export
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
// parent's provider from launch — not only after an in-session switch. Without
// this, a provider switch persisted by another zero process mid-session moves
// new children onto a different provider (and credentials) than the parent.
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
// process env). Inject the real one here to assert the actual env effect.
deps := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, nil
},
}
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
if _, err := buildProvider(resolved, deps); err != nil {
t.Fatalf("buildProvider: %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
}
// A FAILED build must not move the pin: the process never committed to the
// new provider, so children must keep resolving whatever was in effect.
t.Setenv(config.ActiveProviderEnv, "still-current")
failing := appDeps{
exportActiveProvider: config.SetActiveProviderEnv,
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, errors.New("boom")
},
}
if _, err := buildProvider(resolved, failing); err == nil {
t.Fatal("expected build error")
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
}
// No provider profile at all → no provider, no pin change.
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
t.Fatalf("buildProvider(empty): %v", err)
}
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
}
}
// A mid-run --allow-escalation model switch previously rebuilt the provider
// from the PURE resolved profile (the stored key lives only in buildProvider's
// local copy), so the escalated provider was keyless and the next completion
// call 401'd mid-run. Every profile handed to newProvider on this path must
// carry the stored key.
func TestRunExecEscalationSwitchKeepsStoredKey(t *testing.T) {
dataHome := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataHome)
cwd := t.TempDir()
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
t.Skip("registry has no upgrade target for claude-haiku-4.5")
}
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
var builtProfiles []config.ProviderProfile
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{
"exec",
"--allow-escalation",
"--model", "claude-haiku-4.5",
"--init-session-id", "escalation_stored_key_run",
"escalate please",
}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return configPath, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.Provider.APIKeyStored = true // key lives ONLY in the credstore
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
builtProfiles = append(builtProfiles, profile)
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
}
if len(builtProfiles) < 2 {
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
}
for i, profile := range builtProfiles {
if profile.APIKey != "sk-stored-escalation" {
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
}
}
}
-41
View File
@@ -156,44 +156,3 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
-5
View File
@@ -81,9 +81,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "detect" {
return runProvidersDetect(args, stdout, stderr, deps)
}
if command == "models" {
return runProvidersModels(args, stdout, stderr, deps)
}
if command != "list" && command != "current" && command != "catalog" {
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
}
@@ -468,11 +465,9 @@ func writeProvidersHelp(w io.Writer) error {
zero providers use <name> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
Flags:
--json Print JSON summary
+12 -28
View File
@@ -135,11 +135,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
// front — before tool-filter validation and the --list-tools branch — so a
// mode-injected tool filter is validated and reflected in --list-tools, and a
@@ -370,9 +365,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
var modelSwitcher func(context.Context, string) (agent.Provider, error)
if options.allowEscalation {
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
// the escalated provider is authenticated even though resolved.Provider
// is the pure profile — no per-site key handling here.
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
@@ -504,17 +496,15 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
// changed files; the autonomy gate inside the corrector still decides whether
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
// edit_file/write_file inline error diagnostics for the file they just wrote.
// lspShutdown tears down any language-server sessions either half spawned.
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
// failures auto-fix or just report. lspShutdown tears down any language-server
// sessions the LSP half spawned (no-op when self-correct is off).
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -531,7 +521,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
// 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
@@ -707,28 +696,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// unless a changed file's language actually has one installed on PATH. The
// returned cleanup shuts that manager down (terminating any spawned server
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
// stays lazy — no language server is spawned unless an edited file's
// language actually has one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
fileDiagnostics := agent.NewFileDiagnostics(manager)
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil, fileDiagnostics, cleanup
return nil, func() {}
}
manager := lsp.NewManager(workspaceRoot)
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: true,
Autonomy: autonomy,
})
return corrector, fileDiagnostics, cleanup
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
}
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
-86
View File
@@ -24,7 +24,6 @@ func TestProviderHasOAuthLogin(t *testing.T) {
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
@@ -46,88 +45,3 @@ func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
// A profile renamed away from its catalog ID must still get a runtime OAuth
// resolver when the login is stored under the catalog name — and a CASE-VARIANT
// profile name must not drop the catalog-ID candidate (the store is
// case-sensitive; a case-insensitive dedupe would swallow it).
func TestOAuthResolverForProfileFallsBackToCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// A renamed profile resolves the catalog-ID login, and the returned key MUST be
// that login's key — the same key the Codex account-header resolver reads from,
// so bearer and account can never come from different logins.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("renamed profile with a catalog-ID login must get a resolver (unauthenticated-children regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("bound key = %q, want the chatgpt login key (bearer/account must share it)", key)
}
// Name differs from catalog ID only in CASE: the exact-case store key is
// "provider:chatgpt", so "chatgpt" must survive as a distinct candidate.
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("case-variant profile name must still resolve the catalog-ID login (case-insensitive dedupe regression)")
} else if key != oauth.ProviderKey("chatgpt") {
t.Fatalf("case-variant bound key = %q, want the chatgpt login key", key)
}
if resolver, _ := oauthLoginForProfile(config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"}); resolver == nil {
t.Fatal("catalog-named profile must get a resolver")
}
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "openai", CatalogID: "openai"}); resolver != nil || key != "" {
t.Fatalf("a profile with no stored login must get no resolver and an empty key, got key=%q", key)
}
}
// The catalog-ID fallback must NOT reach a sibling profile's OAuth login when
// this profile has its own key — withBearer would erase the key and silently
// bill the model call under the sibling's account (a real cross-profile
// override the fallback introduced). A profile WITH its own resolved key gets no
// resolver even when a catalog-shared login exists.
func TestOAuthResolverForProfileDoesNotOverrideExplicitKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
// A personal login stored under the catalog name.
if err := store.Save(oauth.ProviderKey("anthropic"), oauth.Token{AccessToken: "personal-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// Work profile: same catalog, its OWN resolved API key, deliberately NOT the
// personal OAuth account. It must keep using its key (no resolver attached).
work := config.ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work-key"}
if resolver, key := oauthLoginForProfile(work); resolver != nil || key != "" {
t.Fatalf("a profile with its own API key must not get a catalog-shared OAuth resolver (cross-profile override), got key=%q", key)
}
// A raw auth-header credential must be protected the same way.
header := config.ProviderProfile{Name: "anthropic-hdr", CatalogID: "anthropic", AuthHeaderValue: "Bearer byo"}
if resolver, _ := oauthLoginForProfile(header); resolver != nil {
t.Fatal("a profile with its own auth header must not get a catalog-shared OAuth resolver")
}
// A same-NAME login must not override an explicit key either (the name
// candidate is gated on the profile being keyless, not just the catalog one).
if err := store.Save(oauth.ProviderKey("anthropic-work"), oauth.Token{AccessToken: "name-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed name token: %v", err)
}
if resolver, _ := oauthLoginForProfile(work); resolver != nil {
t.Fatal("a profile with its own API key must not get an OAuth resolver even from a same-name login")
}
// Sanity: a keyless sibling on the same catalog DOES resolve the shared login.
keyless := config.ProviderProfile{Name: "anthropic-oauth", CatalogID: "anthropic"}
if resolver, key := oauthLoginForProfile(keyless); resolver == nil {
t.Fatal("a keyless profile should still resolve the catalog-shared login")
} else if key != oauth.ProviderKey("anthropic") {
t.Fatalf("keyless sibling bound key = %q, want the catalog login key", key)
}
}
+26 -32
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
@@ -11,48 +12,42 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
// returns both a TokenResolver that authenticates model calls with it and the
// credential-store key it bound to. It returns (nil, "") when no login exists —
// keeping API-key users free of any per-request store lookups, since the resolver
// is only attached when a login is present at construction time.
//
// The returned key is the single source of truth for "which login is this
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
// Codex chatgpt-account-id header reads its account from the exact login that
// issued the bearer token, instead of doing a second, independent lookup that
// could select a different login (a backend-rejected mismatch).
//
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
// the profile having no own configured credential) come from the shared
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
return nil
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
return nil
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
@@ -69,5 +64,4 @@ func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResol
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
-23
View File
@@ -3,9 +3,7 @@ package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -59,27 +57,6 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
if len(merged) == 0 {
return nil
}
infos := make([]agent.SkillInfo, 0, len(merged))
for _, skill := range merged {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
}
return infos
}
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
// line, prefixing the diagnostic kind and appending the most specific locator
// available (manifest path, then plugin path, then plugin ID) so an operator can
@@ -18,7 +18,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
}
@@ -47,21 +46,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=LONGCAT_API_KEY",
"setup: zero providers setup longcat --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
-148
View File
@@ -1,148 +0,0 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
type providerModelsOptions struct {
name string
json bool
}
// runProvidersModels lists the models a saved provider actually serves by probing
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
// models no longer needs a config object per model — configure the provider once,
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
// model ids through to the provider).
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderModelsArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
if exitCode != exitSuccess {
return exitCode
}
profile, err := selectProviderForCheck(resolved, options.name)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
ctx, stop := signalContext()
defer stop()
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
if err != nil {
return writeAppError(stderr, err.Error(), exitProvider)
}
if options.json {
items := make([]map[string]any, 0, len(models))
for _, model := range models {
entry := map[string]any{"id": model.ID}
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
items = append(items, entry)
}
payload := map[string]any{
"provider": profile.Name,
"count": len(items),
"models": items,
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
name := strings.TrimSpace(profile.Name)
if name == "" {
name = "provider"
}
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
return exitCrash
}
for _, model := range models {
line := strings.TrimSpace(model.ID)
if description := strings.TrimSpace(model.Description); description != "" {
line += " — " + description
}
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
suffix := "s"
if len(models) == 1 {
suffix = ""
}
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
return exitCrash
}
if len(models) > 0 {
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
return exitCrash
}
}
return exitSuccess
}
// discoveryCredentialProfile resolves the profile's API key the same way the
// runtime does — inline, then the stored credential, then the configured env var —
// so a `providers models` probe authenticates exactly like a real request. Mirrors
// discoveredModelContextWindow's credential resolution.
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
authed := profile
if strings.TrimSpace(authed.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
}
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
}
return authed
}
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
// the provider's model-listing endpoint with no curated-catalog merge or
// coding-model filtering, so a custom provider's full model list is returned.
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
}
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
options := providerModelsOptions{}
for index := 0; index < len(args); index++ {
arg := args[index]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
if options.name != "" {
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
}
options.name = arg
}
}
return options, false, nil
}
-151
View File
@@ -1,151 +0,0 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{
{ID: "team/model-a", Description: "first"},
{ID: "team/model-b"},
}, nil
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
// No name given → the active provider is probed, with its resolved credential.
if probed.Name != "work" {
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
}
out := stdout.String()
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q:\n%s", want, out)
}
}
}
func TestRunProvidersModelsJSON(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
var payload struct {
Provider string `json:"provider"`
Count int `json:"count"`
Models []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"models"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode json: %v\n%s", err, stdout.String())
}
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
t.Fatalf("payload = %#v", payload)
}
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
t.Fatalf("model[0] = %#v", payload.Models[0])
}
// A model with no description omits the field rather than emitting an empty string.
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
t.Fatalf("model[1] = %#v", payload.Models[1])
}
}
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
custom := config.ProviderProfile{
Name: "self-hosted",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://models.internal/v1",
APIKey: "sk-internal",
Model: "house-model",
}
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
}
var probed config.ProviderProfile
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
probed = profile
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
}
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
}
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
t.Fatalf("probed = %#v, want the named custom provider", probed)
}
}
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
called := false
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
called = true
return nil, nil
}
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
if exitCode == exitSuccess {
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
}
if called {
t.Fatal("discovery must not run for an unknown provider")
}
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
}
}
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
deps := commandCenterDeps(t)
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return nil, errors.New("models endpoint returned 404")
}
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
}
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
}
}
+1 -1
View File
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
}
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
+3 -13
View File
@@ -3,7 +3,6 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -315,14 +314,10 @@ func setupRequired(resolved config.ResolvedConfig) bool {
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
// runtime" resolver can never diverge. This is only consulted for a profile with
// no usable API-key credential (setupRequired returns early otherwise), where
// the candidate set is permissive (profile name + catalog ID).
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
@@ -454,11 +449,6 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
case "localhost", "127.0.0.1", "::1":
return true
}
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
return true
}
return false
}
+1 -3
View File
@@ -8,8 +8,6 @@ import (
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestRunSpecialistListShowAndPath(t *testing.T) {
@@ -328,7 +326,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := config.UserConfigDir()
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
+8 -51
View File
@@ -12,7 +12,6 @@ import (
type updateOptions struct {
check bool
apply bool
json bool
repository string
endpoint string
@@ -21,18 +20,6 @@ type updateOptions struct {
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, false)
}
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, true)
}
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
// makes `--apply` the implicit behavior for `zero upgrade` when neither
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
// one of the two so existing scripts around `zero update --check` don't change.
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeUsageError(stderr, err.Error())
@@ -43,19 +30,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
}
return exitSuccess
}
if options.check && options.apply {
return writeUsageError(stderr, "Pass only one of --check or --apply.")
if !options.check {
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
}
if !options.check && !options.apply {
if !defaultApply {
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
}
options.apply = true
}
if options.apply && options.target != "" {
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
}
updateOptions := update.Options{
checkOptions := update.Options{
CurrentVersion: version,
Repository: options.repository,
Endpoint: options.endpoint,
@@ -66,26 +44,10 @@ func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps ap
if err != nil {
return writeUsageError(stderr, err.Error())
}
updateOptions.GOOS = target.GOOS
updateOptions.GOARCH = target.GOARCH
checkOptions.GOOS = target.GOOS
checkOptions.GOARCH = target.GOARCH
}
if options.apply {
result, err := deps.applyUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
return exitCrash
}
return exitSuccess
}
result, err := deps.checkUpdate(context.Background(), updateOptions)
result, err := deps.checkUpdate(context.Background(), checkOptions)
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
}
@@ -110,8 +72,6 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--apply":
options.apply = true
case arg == "--json":
options.json = true
case arg == "--repo":
@@ -195,17 +155,14 @@ func parseUpdateTarget(value string) (string, error) {
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [flags]
zero update --apply [flags]
zero upgrade [flags]
Flags:
--check Check the latest GitHub release without installing
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--json Print the update check result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
--target <platform> Release target to verify (for example windows-x64)
-h, --help Show this help
`)
return err
-141
View File
@@ -662,144 +662,3 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
}
func TestParseChangesArgsAuto(t *testing.T) {
// 1. Correct parsing of auto
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, "commit")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v)", args)
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto") {
t.Fatalf("expected --auto rejection on inspect, got %v", err)
}
// 3. Reject both --message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected --message and --auto conflict error, got %v", err)
}
// 4. Reject both empty message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
events := make(chan zeroruntime.StreamEvent, 2)
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(events)
return events, nil
}
func TestRunChangesCommitAuto(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
response: "```\nfeat: auto commit message\n```",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
commitCalled := false
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Name = "openai"
cfg.Provider.Model = "gpt-4o"
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
commitCalled = true
if options.Message != "feat: auto commit message" {
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
}
return zerogit.CommitResult{
Root: cwd,
Message: options.Message,
Committed: true,
CommitHash: "abc1234",
Before: summary,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !commitCalled {
t.Fatal("expected commitChanges to be called")
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
}
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
}
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
t.Fatalf("expected status message in stdout, got %q", stdout.String())
}
t.Run("EmptyLLMResponse", func(t *testing.T) {
mockProvEmpty := &mockCommitMsgProvider{
response: " \n\n ",
}
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProvEmpty, nil
},
})
if code == exitSuccess {
t.Fatal("expected command to fail when LLM returns empty response, got success")
}
if !strings.Contains(stderr.String(), "empty commit message") {
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
}
})
}
+3 -96
View File
@@ -6,16 +6,13 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/testrunner"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type worktreeCommandOptions struct {
@@ -38,10 +35,8 @@ type changesCommandOptions struct {
cwd string
baseRef string
message string
hasMessage bool
dryRun bool
maxDiffBytes int
auto bool
}
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -203,52 +198,9 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
case "commit":
var message string
if options.auto {
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
Cwd: workspaceRoot,
MaxDiffBytes: options.maxDiffBytes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
}
if summary.Clean {
return writeExecUsageError(stderr, "no changes to commit")
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
}
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
return exitCrash
}
}
safeSummary := redactChangeSummary(summary)
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
}
message = msg
} else {
message = options.message
}
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
Cwd: workspaceRoot,
Message: message,
Message: options.message,
DryRun: options.dryRun,
MaxDiffBytes: options.maxDiffBytes,
})
@@ -443,11 +395,9 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.message = value
options.hasMessage = true
index = next
case strings.HasPrefix(arg, "--message="):
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
options.hasMessage = true
case arg == "--diff-bytes":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -465,19 +415,14 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
if command != "commit" && (options.message != "" || options.dryRun) {
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
}
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
@@ -767,47 +712,9 @@ Flags:
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
-h, --help Show this help
`)
return err
}
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
msg := strings.TrimSpace(collected.Text)
if strings.HasPrefix(msg, "```") {
if idx := strings.Index(msg, "\n"); idx != -1 {
msg = msg[idx+1:]
}
}
msg = strings.TrimSuffix(msg, "```")
msg = strings.TrimSpace(msg)
if msg == "" {
return "", fmt.Errorf("provider returned empty commit message")
}
return msg, nil
}
@@ -1,49 +0,0 @@
package config
import (
"os"
"testing"
)
// SetActiveProviderEnv must export the provider name so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) resolves the SAME active provider. A blank name CLEARS it.
func TestSetActiveProviderEnvRoundTrips(t *testing.T) {
t.Setenv(ActiveProviderEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(ActiveProviderEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetActiveProviderEnv("") // blank with nothing set stays unset
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("empty name set the env to %q, want unset", got)
}
// Regression: switching from a named provider back to an unnamed/default profile
// must CLEAR a previously exported value, not leave it stale for children.
if err := os.Setenv(ActiveProviderEnv, "stale-provider"); err != nil {
t.Fatalf("seed stale: %v", err)
}
SetActiveProviderEnv("") // blank must unset the stale value
if got := os.Getenv(ActiveProviderEnv); got != "" {
t.Fatalf("blank name left stale env %q, want cleared", got)
}
cfgStale := &FileConfig{}
applyEnv(cfgStale, nil)
if cfgStale.ActiveProvider != "" {
t.Fatalf("applyEnv still resolved stale provider %q after clear", cfgStale.ActiveProvider)
}
SetActiveProviderEnv("ollama-cloud")
if got := os.Getenv(ActiveProviderEnv); got != "ollama-cloud" {
t.Fatalf("env = %q, want ollama-cloud", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value selects the active provider.
cfg := &FileConfig{}
applyEnv(cfg, nil)
if cfg.ActiveProvider != "ollama-cloud" {
t.Fatalf("applyEnv ActiveProvider = %q, want ollama-cloud (inherited env not read)", cfg.ActiveProvider)
}
}
-57
View File
@@ -155,63 +155,6 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error)
return migrated, nil
}
// HasConfiguredCredential reports whether the profile is set up to authenticate
// with its own API key (inline, stored, or a raw auth header). It is the single
// definition of "this profile is key-authed", shared by OAuthLoginCandidates and
// the cli/tui setup surfaces so they never disagree about whether a profile is
// keyed. APIKeyEnv is deliberately NOT included: an env var may be unset at
// runtime while the profile actually relies on an OAuth login, so an
// env-configured-but-empty profile must still be allowed to resolve a token.
func (profile ProviderProfile) HasConfiguredCredential() bool {
return strings.TrimSpace(profile.APIKey) != "" ||
profile.APIKeyStored ||
strings.TrimSpace(profile.AuthHeaderValue) != ""
}
// 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.
//
// It returns NO candidates for a profile that carries its own configured
// credential: attaching an OAuth resolver to such a profile makes withBearer
// erase the key the instant a token resolves, silently routing/billing the call
// through the OAuth account instead of the intended key (the cross-profile
// override the review caught — and, via the profile name, a same-name login
// overriding an explicit key). A stored-key profile that reaches this keyless
// (e.g. a transiently unreadable keyring) is likewise treated as key-auth, so it
// fails with a clear missing-credential error rather than silently borrowing an
// unrelated OAuth login. APIKeyEnv is intentionally not part of that gate (an
// env var may be unset while the profile relies on OAuth).
//
// For a keyless profile the profile name comes first (a login under your own
// profile name is unambiguously yours), then the catalog ID as a FALLBACK (so a
// profile renamed away from its catalog ID — e.g. {name:"codex",
// catalogID:"chatgpt"} — still finds a `zero auth login chatgpt` token).
// Candidates are trimmed, blank-skipped, and de-duplicated CASE-SENSITIVELY — the
// OAuth token store is a case-sensitive map, so "ChatGPT" and "chatgpt" are
// distinct keys and both must survive.
func (profile ProviderProfile) OAuthLoginCandidates() []string {
if profile.HasConfiguredCredential() {
return nil
}
var names []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
for _, existing := range names {
if existing == s { // case-sensitive: the store lookup is case-sensitive
return
}
}
names = append(names, s)
}
add(profile.Name)
add(profile.CatalogID)
return names
}
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
// this only supplies a key for providers whose secret lives in the store. A nil
-66
View File
@@ -172,69 +172,3 @@ func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
t.Fatalf("no inline key expected, got %q", p.APIKey)
}
}
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
// fallback ONLY when the profile has no effective own credential, and dedupes
// case-sensitively (the OAuth store is a case-sensitive map).
func TestOAuthLoginCandidates(t *testing.T) {
cases := []struct {
name string
profile ProviderProfile
want []string
}{
{
name: "renamed keyless profile falls back to catalog id",
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
want: []string{"codex", "chatgpt"},
},
{
name: "case-variant name keeps distinct catalog-id candidate",
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
want: []string{"ChatGPT", "chatgpt"},
},
{
name: "exact-duplicate name and catalog id collapse",
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
want: []string{"chatgpt"},
},
{
// A configured key must block ALL candidates (name included): a login
// under the profile's own name would otherwise erase the key too.
name: "own inline key blocks every candidate",
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
want: nil,
},
{
name: "own auth header blocks every candidate",
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
want: nil,
},
{
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
// transiently unreadable keyring): don't silently borrow an OAuth login.
name: "stored-key profile blocks every candidate",
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
want: nil,
},
{
// APIKeyEnv is NOT a configured credential for gating: the env var may be
// unset while the profile relies on an OAuth login, so candidates stand.
name: "env-only profile still yields candidates",
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
want: []string{"xai"},
},
{
name: "empty profile yields no candidates",
profile: ProviderProfile{},
want: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.profile.OAuthLoginCandidates()
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
}
})
}
}
-56
View File
@@ -1,56 +0,0 @@
package config
import (
"os"
"testing"
)
// SetMaxTurnsEnv must export the per-run turn budget so a child process (whose
// resolution reads the inherited environment via the nil-map -> os.Getenv path in
// applyEnv) runs with the SAME budget the user set via /turns. n <= 0 is a no-op,
// and a non-numeric/zero env value must not clobber the configured budget.
func TestSetMaxTurnsEnvRoundTrips(t *testing.T) {
t.Setenv(MaxTurnsEnv, "") // register cleanup; restores the original after
if err := os.Unsetenv(MaxTurnsEnv); err != nil {
t.Fatalf("unset: %v", err)
}
SetMaxTurnsEnv(0) // non-positive must not set anything
if got := os.Getenv(MaxTurnsEnv); got != "" {
t.Fatalf("n<=0 set the env to %q, want unset", got)
}
SetMaxTurnsEnv(150)
if got := os.Getenv(MaxTurnsEnv); got != "150" {
t.Fatalf("env = %q, want 150", got)
}
// applyEnv with a nil map is the exact path a child uses: it falls back to
// os.Getenv, so the inherited value sets the budget.
cfg := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg, nil)
if cfg.MaxTurns != 150 {
t.Fatalf("applyEnv MaxTurns = %d, want 150 (inherited env not read)", cfg.MaxTurns)
}
// A garbage env value must leave the configured budget untouched.
if err := os.Setenv(MaxTurnsEnv, "not-a-number"); err != nil {
t.Fatalf("set: %v", err)
}
cfg2 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg2, nil)
if cfg2.MaxTurns != defaultMaxTurns {
t.Fatalf("garbage env clobbered MaxTurns to %d, want default %d", cfg2.MaxTurns, defaultMaxTurns)
}
// An over-ceiling env value (e.g. a raw shell export that bypasses the /turns
// clamp) must be clamped to MaxTurnsCeiling at the read site.
if err := os.Setenv(MaxTurnsEnv, "999999"); err != nil {
t.Fatalf("set: %v", err)
}
cfg3 := &FileConfig{MaxTurns: defaultMaxTurns}
applyEnv(cfg3, nil)
if cfg3.MaxTurns != MaxTurnsCeiling {
t.Fatalf("applyEnv MaxTurns = %d, want clamped to ceiling %d", cfg3.MaxTurns, MaxTurnsCeiling)
}
}
+1 -21
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
@@ -34,32 +33,13 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
}
func DefaultUserConfigPath() (string, error) {
userConfigDir, err := UserConfigDir()
userConfigDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(userConfigDir, "zero", "config.json"), nil
}
// UserConfigDir returns the base directory Zero stores per-user config under.
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
// matching Linux and Claude Code) so a single config path works cross-platform.
// $XDG_CONFIG_HOME still wins on macOS when set.
func UserConfigDir() (string, error) {
if runtime.GOOS == "darwin" {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
return filepath.Join(home, ".config"), nil
}
return os.UserConfigDir()
}
func existingConfigFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
+1 -19
View File
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := UserConfigDir()
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
@@ -130,21 +130,3 @@ func mkdirAll(t *testing.T, path string) {
t.Fatalf("create directory %s: %v", path, err)
}
}
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("macOS-specific config path behavior")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
path, err := DefaultUserConfigPath()
if err != nil {
t.Fatalf("DefaultUserConfigPath() error = %v", err)
}
want := filepath.Join(home, ".config", "zero", "config.json")
if path != want {
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
}
}
+10 -163
View File
@@ -2,11 +2,9 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -15,40 +13,7 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// ErrNoActiveProvider marks a resolve failure caused solely by a missing or
// unresolvable active provider. The interactive TUI treats this as "needs
// onboarding" (drop into the setup wizard) rather than a fatal config error.
var ErrNoActiveProvider = errors.New("no active provider configured")
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
// provider missing a model with no catalog default to fall back on (custom
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
// and drops into the setup wizard so the user can fix it; headless commands
// (zero config, zero exec) still fail with the actionable message.
var ErrProviderRequiresModel = errors.New("provider requires model")
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
// changing its message: the sentinel's own text must not prefix what the user
// sees (the wrapped message is already complete and actionable).
type setupFixableError struct {
err error
sentinel error
}
func (e *setupFixableError) Error() string { return e.err.Error() }
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
// too low for real multi-step agentic work (agents ran out mid-task before reaching
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
const defaultMaxTurns = 50
// MaxTurnsCeiling caps the per-run tool-turn budget so a stray env value or typo
// can't set an absurd ceiling. Shared between applyEnv (read site) and the /turns
// command (write site) so the bound holds even if the env is set by a raw shell.
const MaxTurnsCeiling = 500
const defaultMaxTurns = 30
// defaultDeferThreshold is the number of deferred-eligible (MCP) tools at which
// Zero collapses their full JSON schemas into compact `tool_search` reminder
@@ -130,11 +95,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
// On ErrNoActiveProvider, providers may still hold the successfully
// normalized (but active-less) profile list — keep it so a caller can fall
// back to an already-configured usable provider instead of treating this
// like a config with nothing set up at all.
return ResolvedConfig{Providers: providers}, err
return ResolvedConfig{}, err
}
return ResolvedConfig{
@@ -148,7 +109,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -226,11 +186,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if src.Preferences.Recaps != nil {
dst.Preferences.Recaps = src.Preferences.Recaps
}
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -252,17 +208,8 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
// config: a cloned repo's .zero/config.json must not be able to grant
// itself write access outside the workspace. Global config and CLI flags
// are the only config sources for write roots.
//
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
// access for shell commands. Matches the AdditionalWriteRoots posture.
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
// Normalize: anything that isn't literally "allow" counts as deny.
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
dst.Sandbox.Network = network
}
// Silently ignore "allow" from project config — not an error, just
// a privilege the project scope does not have.
dst.Sandbox.Network = network
}
if src.Sandbox.BlockUnixSockets {
dst.Sandbox.BlockUnixSockets = true
@@ -283,7 +230,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
@@ -412,18 +358,6 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
return ""
}
// catalogDefaultModel returns the default model of a catalog provider ("" when
// the id is unknown), used to fill a missing profile.Model for the official-API
// kinds so a hand-written profile without a model resolves instead of bricking
// every command that resolves config up front (zero config, bare zero setup).
func catalogDefaultModel(catalogID string) string {
descriptor, ok := providercatalog.Get(catalogID)
if !ok {
return ""
}
return strings.TrimSpace(descriptor.DefaultModel)
}
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := providercatalog.NormalizeID(profile.CatalogID)
if catalogID == "" {
@@ -497,53 +431,11 @@ func mergeProfile(base ProviderProfile, next ProviderProfile) ProviderProfile {
return base
}
// ActiveProviderEnv selects the active provider profile by name (read in applyEnv).
const ActiveProviderEnv = "ZERO_PROVIDER"
// SetActiveProviderEnv exports the active provider name to the process environment
// so a spawned child process (which inherits the environment) resolves the SAME
// provider profile — and therefore the same credentials (env key / stored key /
// OAuth) — as its parent. Without this a sub-agent re-resolves config.json's
// default provider and can land on one whose credentials don't match the parent's
// live selection, failing auth the instant it spawns. A blank name CLEARS the
// variable: switching back to an unnamed/default profile must not keep exporting a
// stale provider to children.
func SetActiveProviderEnv(name string) {
name = strings.TrimSpace(name)
if name == "" {
_ = os.Unsetenv(ActiveProviderEnv)
return
}
_ = os.Setenv(ActiveProviderEnv, name)
}
// MaxTurnsEnv overrides the per-run tool-turn budget by name (read in applyEnv).
const MaxTurnsEnv = "ZERO_MAX_TURNS"
// SetMaxTurnsEnv exports the per-run tool-turn budget to the process environment so
// a spawned child (sub-agent / swarm member, which inherits the environment) runs
// with the SAME budget the user set via /turns. Without it a child re-resolves
// config.json's default and a large delegated task can exhaust its turns mid-run
// (exit 4 / max-turns). No-op for n <= 0.
func SetMaxTurnsEnv(n int) {
if n > 0 {
_ = os.Setenv(MaxTurnsEnv, strconv.Itoa(n))
}
}
func applyEnv(cfg *FileConfig, env map[string]string) {
activeProvider := strings.TrimSpace(envValue(env, ActiveProviderEnv))
activeProvider := strings.TrimSpace(envValue(env, "ZERO_PROVIDER"))
if activeProvider != "" {
cfg.ActiveProvider = activeProvider
}
if maxTurns := strings.TrimSpace(envValue(env, MaxTurnsEnv)); maxTurns != "" {
if n, err := strconv.Atoi(maxTurns); err == nil && n > 0 {
if n > MaxTurnsCeiling {
n = MaxTurnsCeiling
}
cfg.MaxTurns = n
}
}
applyProviderEnv(cfg, ProviderKindOpenAI, envProfile{
Name: string(ProviderKindOpenAI),
@@ -590,17 +482,9 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
if profile.Name == "" {
profile.Name = string(providerKind)
}
// A non-official baseURL from the environment signals a proxy/gateway, so promote
// the first-party kind to its -compatible transport (which accepts a custom URL).
// Env-only by design: a custom baseURL in a project config.json is left as-is and
// rejected by Resolve, so an untrusted project can't redirect a first-party key to
// another host.
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
profile.ProviderKind = ProviderKindOpenAICompatible
}
if providerKind == ProviderKindAnthropic && baseURL != "" && !isOfficialAnthropicBaseURL(baseURL) {
profile.ProviderKind = ProviderKindAnthropicCompat
}
// When the env supplies only credentials (no baseURL) for a provider name that
// already exists, don't force the standard transport kind — a same-named
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
@@ -680,7 +564,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -716,24 +599,6 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
}
}
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
if src.ToggleDetailed != "" {
dst.ToggleDetailed = src.ToggleDetailed
}
if src.ToggleMouse != "" {
dst.ToggleMouse = src.ToggleMouse
}
if src.CycleReasoning != "" {
dst.CycleReasoning = src.CycleReasoning
}
if src.TogglePlan != "" {
dst.TogglePlan = src.TogglePlan
}
if src.ToggleSidebar != "" {
dst.ToggleSidebar = src.ToggleSidebar
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
@@ -812,16 +677,11 @@ func unionStrings(base []string, extra []string) []string {
}
type normalizeOptions struct {
// defaultModels fills a missing profile.Model for the official-API provider
// kinds (openai from the model registry; anthropic/google from their catalog
// descriptors). Off for provider-command configs (command.go), which must
// surface exactly what the external command returned rather than inventing
// a model behind its back.
defaultModels bool
defaultOpenAIModel bool
}
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
}
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
@@ -836,7 +696,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if len(providers) == 0 {
if activeName != "" {
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
return []ProviderProfile{}, ProviderProfile{}, nil
}
@@ -868,17 +728,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
// Return the successfully normalized list alongside the error (rather than
// nil) so a caller like the interactive TUI can still fall back to an
// already-configured, usable provider instead of forcing a full
// re-onboarding wizard just because none was marked active.
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, &setupFixableError{
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
sentinel: ErrProviderRequiresModel,
}
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
}
return normalized, active, nil
@@ -925,7 +778,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindOpenAI:
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
profile.BaseURL = OpenAIBaseURL
if options.defaultModels && profile.Model == "" {
if options.defaultOpenAIModel && profile.Model == "" {
profile.Model = modelregistry.DefaultModelID
}
return profile, nil
@@ -942,9 +795,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindAnthropic:
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
profile.BaseURL = AnthropicBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("anthropic")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
@@ -959,9 +809,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
case ProviderKindGoogle:
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
profile.BaseURL = GoogleBaseURL
if options.defaultModels && profile.Model == "" {
profile.Model = catalogDefaultModel("google")
}
return profile, nil
}
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
+2 -311
View File
@@ -1,7 +1,6 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
@@ -617,35 +616,6 @@ func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
}
}
func TestResolveUsesAnthropicEnvBaseURLAsCompatible(t *testing.T) {
// ANTHROPIC_BASE_URL pointing at a proxy/gateway must resolve to an
// anthropic-compatible provider (mirroring OPENAI_BASE_URL), not the "requires
// official baseURL" rejection — issue #479. The env is user-controlled, so a
// custom URL there is a deliberate gateway choice, unlike a project config.json.
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_MODEL": "claude-custom",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "anthropic" {
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want anthropic-compatible", resolved.Provider.ProviderKind)
}
if resolved.Provider.BaseURL != "https://gateway.example/anthropic" {
t.Fatalf("BaseURL = %q, want gateway URL", resolved.Provider.BaseURL)
}
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-custom" {
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
}
}
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
@@ -913,8 +883,8 @@ func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
if HasProviderProfile(resolved.Provider) {
t.Fatalf("Provider = %#v, want zero value", resolved.Provider)
}
if resolved.MaxTurns != defaultMaxTurns {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
if resolved.MaxTurns != 30 {
t.Fatalf("MaxTurns = %d, want 30", resolved.MaxTurns)
}
}
@@ -928,9 +898,6 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
t.Fatalf("error = %q, want active provider missing message", err.Error())
}
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if resolved.ActiveProvider != "" {
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
}
@@ -945,25 +912,6 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
}
}
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
// Multiple providers configured (e.g. via `zero provider add`) but
// activeProvider is blank/stale — a caller like the interactive TUI still
// needs the normalized list to fall back to an already-usable provider
// instead of forcing a full re-onboarding wizard.
path := writeConfig(t, `{"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if len(resolved.Providers) != 2 {
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
@@ -1107,130 +1055,6 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
}
}
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "mini-cn",
"providers": [{
"name": "mini-cn",
"catalog_id": "minimaxi-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"MINIMAXI_API_KEY": "sk-cn-mini",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "minimaxi-cn" {
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
}
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "MiniMax-M3" {
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-cn-mini" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
// for users who still target the China hosting; "zai" itself now points at
// api.z.ai (international). The two env-vars are intentionally distinct
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
// one side cannot be silently used against the other.
path := writeConfig(t, `{
"activeProvider": "zai-cn",
"providers": [{
"name": "zai-cn",
"catalog_id": "zai-cn"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZHIPU_API_KEY": "sk-zhipu-cn",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai-cn" {
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zhipu-cn" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
// "zai" now resolves to the international endpoint; the catalog default
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
path := writeConfig(t, `{
"activeProvider": "zai-intl",
"providers": [{
"name": "zai-intl",
"catalog_id": "zai"
}]
}`)
resolved, err := Resolve(ResolveOptions{
ProjectConfigPath: path,
Env: map[string]string{
"ZAI_API_KEY": "sk-zai-intl",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.CatalogID != "zai" {
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
}
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
}
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
}
if resolved.Provider.Model != "glm-4.5" {
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
}
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
}
if resolved.Provider.APIKey != "sk-zai-intl" {
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
}
}
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "local",
@@ -1445,45 +1269,6 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
}
}
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
// 1. Project config tries to set network to "allow" (should be ignored).
userPath := writeConfig(t, `{}`)
projectPath := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Sandbox.Network == "allow" {
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
}
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
userPath2 := writeConfig(t, `{
"sandbox": {"network": "allow"}
}`)
projectPath2 := writeConfig(t, `{
"sandbox": {"network": "deny"}
}`)
resolved2, err := Resolve(ResolveOptions{
UserConfigPath: userPath2,
ProjectConfigPath: projectPath2,
Env: map[string]string{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved2.Sandbox.Network != "deny" {
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
}
}
func TestResolveNotifyValid(t *testing.T) {
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
@@ -1766,97 +1551,3 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
}
}
// The reported brick: a hand-written google profile with an apiKey but no
// model made EVERY resolving command fail ("provider google requires model"),
// including zero config and bare zero setup — the only commands that could
// have fixed it. Official-API kinds now fall back to their catalog default
// model, exactly like the openai kind always has.
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
}
if resolved.Provider.Model != "gemini-2.5-pro" {
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
}
if resolved.Provider.BaseURL != GoogleBaseURL {
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
}
}
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "anthropic",
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
}
if resolved.Provider.Model != "claude-sonnet-4.5" {
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
}
}
// An explicitly configured model must always win over the catalog default.
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "google",
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.Model != "gemini-2.5-flash" {
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
}
}
// Provider-command configs must NOT get invented models: the without-defaults
// path surfaces exactly what the external command returned.
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
_, _, err := normalizeProvidersWithoutModelDefaults(
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
"google", map[string]string{})
if err == nil {
t.Fatal("provider-command path must still require an explicit model")
}
if !strings.Contains(err.Error(), "requires model") {
t.Fatalf("error = %q, want requires-model", err.Error())
}
// The residual error must tell the user how to fix it.
if !strings.Contains(err.Error(), "config.json") {
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
}
}
// The residual requires-model failure (custom endpoint, no catalog default)
// must be tagged setup-fixable WITHOUT changing its message: the interactive
// TUI matches the sentinel to drop into the wizard, while headless commands
// print the exact actionable text.
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "gw",
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
}`)
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err == nil {
t.Fatal("expected requires-model error for a custom endpoint without model")
}
if !errors.Is(err, ErrProviderRequiresModel) {
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
}
// The sentinel must NOT prefix the user-facing message.
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
}
if strings.Contains(err.Error(), "sk-x") {
t.Fatalf("error leaked API key: %q", err.Error())
}
}
-30
View File
@@ -97,10 +97,6 @@ type ToolsConfig struct {
type PreferencesConfig struct {
FavoriteModels []string `json:"favoriteModels,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).
Theme string `json:"theme,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
@@ -112,25 +108,6 @@ func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
// can remap. An empty string means "use the built-in default" for that action.
type KeyBindingDef string
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
// via config.json. Each field defaults to a sensible built-in when empty.
type KeyBindingsConfig struct {
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
// ToggleMouse toggles mouse capture release (default: ctrl+e).
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
}
// 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.
@@ -225,7 +202,6 @@ type FileConfig struct {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
@@ -240,7 +216,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
@@ -253,7 +228,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -278,7 +252,6 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -293,7 +266,6 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -340,7 +312,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
@@ -367,7 +338,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+1 -28
View File
@@ -66,13 +66,10 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
}
func TestValidateFileMissingModelWarns(t *testing.T) {
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
// on, so a missing model is still a real issue — and the message must tell
// the user how to fix it.
path := writeValidateFixture(t, `{
"activeProvider": "main",
"providers": [
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
{"name": "main", "provider_kind": "anthropic"}
]
}`)
@@ -83,30 +80,6 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
if !strings.Contains(issues[0].Message, "requires model") {
t.Fatalf("expected requires-model issue, got %#v", issues)
}
if !strings.Contains(issues[0].Message, "config.json") {
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
}
}
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
// Official-API kinds (anthropic/google) fall back to their catalog default
// model, so a hand-written model-less profile validates clean instead of
// bricking zero config / bare zero setup — the only commands that could
// have fixed it (the reported google case).
path := writeValidateFixture(t, `{
"activeProvider": "google",
"providers": [
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
]
}`)
_, issues := ValidateFile(path)
for _, issue := range issues {
if strings.Contains(issue.Message, "requires model") {
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
}
}
}
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
-22
View File
@@ -154,28 +154,6 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
return cfg, nil
}
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
// (read-modify-atomic-write). A blank theme clears the stored preference.
func SetTheme(path string, theme 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)
}
cfg.Preferences.Theme = strings.TrimSpace(theme)
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))
-33
View File
@@ -272,39 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
}
}
func TestSetThemePersistsUserPreference(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 := SetTheme(path, " dracula ")
if err != nil {
t.Fatalf("SetTheme() error = %v", err)
}
if cfg.Preferences.Theme != "dracula" {
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
}
persisted := readConfigFixture(t, path)
if persisted.Preferences.Theme != "dracula" {
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
}
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
}
// A blank value clears the stored preference.
if cfg, err = SetTheme(path, ""); err != nil {
t.Fatalf("SetTheme(\"\") error = %v", err)
}
if cfg.Preferences.Theme != "" {
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
}
}
func TestRecapsPreferenceRoundTrips(t *testing.T) {
// Default (unset) is ON.
if !(PreferencesConfig{}).RecapsEnabled() {
+9 -25
View File
@@ -131,45 +131,29 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
// lspServersCheck reports which language servers ZERO would use are present on
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
// those languages — so the worst status is WARN, and each missing server gets an
// actionable install command keyed by its binary name. Only the tier-1 servers
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
// for breadth are listed informationally under missingOptional, because warning
// about a missing zls on a machine with no Zig code would be permanent noise.
// actionable install command keyed by its binary name.
func lspServersCheck(lookup func(string) (string, error)) Check {
if lookup == nil {
lookup = exec.LookPath
}
core := map[string]bool{}
for _, binary := range lsp.CoreServerBinaries() {
core[binary] = true
}
present := map[string]any{}
missing := map[string]any{}
missingOptional := map[string]any{}
for _, binary := range lsp.ServerBinaries() {
if _, err := lookup(binary); err == nil {
present[binary] = "on PATH"
continue
}
if core[binary] {
missing[binary] = lspRemedy(binary)
} else {
missingOptional[binary] = lspRemedy(binary)
}
}
details := map[string]any{"present": present}
if len(missingOptional) > 0 {
details["missingOptional"] = missingOptional
missing[binary] = lspRemedy(binary)
}
if len(missing) == 0 {
message := "All core language servers are available on PATH."
if len(missingOptional) > 0 {
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
}
return check("lsp.servers", "LSP servers", StatusPass, message, details)
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
"present": present,
})
}
details["missing"] = missing
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
"present": present,
"missing": missing,
})
}
// lspRemedy returns an actionable install command for a missing language-server
+15 -123
View File
@@ -9,134 +9,26 @@ import (
// serverCommands maps a file extension to the language-server command (argv) ZERO
// will spawn. The first element is the binary looked up on PATH; missing binaries
// are not an error, the agent just degrades to text-only for that file. Every
// command is the language's community-standard server invoked in stdio mode, so
// a wrong guess can never spawn something surprising — only fail the PATH check.
// are not an error, the agent just degrades to text-only for that file.
var serverCommands = map[string][]string{
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".mts": {"typescript-language-server", "--stdio"},
".cts": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".mjs": {"typescript-language-server", "--stdio"},
".cjs": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".pyi": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
".c": {"clangd"},
".h": {"clangd"},
".cpp": {"clangd"},
".cc": {"clangd"},
".cxx": {"clangd"},
".hpp": {"clangd"},
".java": {"jdtls"},
".kt": {"kotlin-language-server"},
".kts": {"kotlin-language-server"},
".rb": {"ruby-lsp"},
".php": {"intelephense", "--stdio"},
".zig": {"zls"},
".lua": {"lua-language-server"},
".ex": {"elixir-ls"},
".exs": {"elixir-ls"},
".hs": {"haskell-language-server-wrapper", "--lsp"},
".swift": {"sourcekit-lsp"},
".ml": {"ocamllsp"},
".mli": {"ocamllsp"},
".scala": {"metals"},
".clj": {"clojure-lsp"},
".cljs": {"clojure-lsp"},
".cljc": {"clojure-lsp"},
".dart": {"dart", "language-server"},
".gleam": {"gleam", "lsp"},
".nix": {"nixd"},
".tf": {"terraform-ls", "serve"},
".sh": {"bash-language-server", "start"},
".bash": {"bash-language-server", "start"},
".yaml": {"yaml-language-server", "--stdio"},
".yml": {"yaml-language-server", "--stdio"},
".json": {"vscode-json-language-server", "--stdio"},
".css": {"vscode-css-language-server", "--stdio"},
".scss": {"vscode-css-language-server", "--stdio"},
".less": {"vscode-css-language-server", "--stdio"},
".html": {"vscode-html-language-server", "--stdio"},
".svelte": {"svelteserver", "--stdio"},
".vue": {"vue-language-server", "--stdio"},
".astro": {"astro-ls", "--stdio"},
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
}
// languageIDs maps a file extension to the LSP languageId used in didOpen.
var languageIDs = map[string]string{
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
".py": "python",
".pyi": "python",
".rs": "rust",
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".hpp": "cpp",
".java": "java",
".kt": "kotlin",
".kts": "kotlin",
".rb": "ruby",
".php": "php",
".zig": "zig",
".lua": "lua",
".ex": "elixir",
".exs": "elixir",
".hs": "haskell",
".swift": "swift",
".ml": "ocaml",
".mli": "ocaml",
".scala": "scala",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".dart": "dart",
".gleam": "gleam",
".nix": "nix",
".tf": "terraform",
".sh": "shellscript",
".bash": "shellscript",
".yaml": "yaml",
".yml": "yaml",
".json": "json",
".css": "css",
".scss": "scss",
".less": "less",
".html": "html",
".svelte": "svelte",
".vue": "vue",
".astro": "astro",
}
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
// configured above for breadth is reported informationally only — warning about
// a missing zls on a machine with no Zig code would be permanent noise.
var coreServerBinaries = []string{
"gopls",
"typescript-language-server",
"pyright-langserver",
"rust-analyzer",
}
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
func CoreServerBinaries() []string {
binaries := append([]string(nil), coreServerBinaries...)
sort.Strings(binaries)
return binaries
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".py": "python",
".rs": "rust",
}
// ServerBinaries returns the unique set of language-server binaries ZERO may
+21 -166
View File
@@ -1,17 +1,16 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -212,85 +211,6 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
}
}
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/mcp" {
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
return
}
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
message := readHTTPRPCMessage(t, request)
switch message.Method {
case "initialize":
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
case "notifications/initialized":
response.WriteHeader(http.StatusAccepted)
default:
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
}
}))
defer testServer.Close()
client, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: testServer.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("redirect target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeHTTP,
URL: redirector.URL + "/mcp",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -420,53 +340,6 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
}
}
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := request.Header.Get("X-Api-Key"); got != "" {
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
}
response.WriteHeader(http.StatusAccepted)
}))
defer target.Close()
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if got := request.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
http.Error(response, "missing auth", http.StatusUnauthorized)
return
}
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
http.Error(response, "bad request", http.StatusNotFound)
return
}
response.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
if flusher, ok := response.(http.Flusher); ok {
flusher.Flush()
}
}))
defer stream.Close()
_, err := Connect(ctx, Server{
Name: "docs",
Type: ServerTypeSSE,
URL: stream.URL + "/sse",
Headers: map[string]string{"X-Api-Key": "secret"},
})
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
}
}
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -520,43 +393,28 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
}
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
// reading the outgoing request, matching production ordering. With responses
// pre-loaded, the client's reader goroutine could consume the matching
// response — and hit EOF — before request() registered its pending id,
// flaking as "request() error = EOF" on fast runners.
serverReader, clientWriter := io.Pipe() // client → server
clientReader, serverWriter := io.Pipe() // server → client
t.Cleanup(func() {
_ = clientWriter.Close()
_ = serverWriter.Close()
})
serverErr := make(chan error, 1)
go func() {
requests := newMessageReader(serverReader)
request, err := requests.read()
if err != nil {
serverErr <- err
return
}
responses := newMessageWriter(serverWriter)
for _, message := range []rpcMessage{
{Method: "notifications/progress"},
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
} {
if err := responses.write(message); err != nil {
serverErr <- err
return
}
}
serverErr <- nil
}()
var incoming bytes.Buffer
incomingWriter := newMessageWriter(&incoming)
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 99,
Error: &rpcError{Code: -32000, Message: "wrong response"},
}); err != nil {
t.Fatal(err)
}
if err := incomingWriter.write(rpcMessage{
ID: 1,
Result: mustRaw(map[string]any{"value": "matched"}),
}); err != nil {
t.Fatal(err)
}
var outgoing bytes.Buffer
client := &Client{
reader: newMessageReader(clientReader),
writer: newMessageWriter(clientWriter),
reader: newMessageReader(&incoming),
writer: newMessageWriter(&outgoing),
nextID: 1,
}
var result struct {
@@ -568,9 +426,6 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
if result.Value != "matched" {
t.Fatalf("result.Value = %q, want matched response", result.Value)
}
if err := <-serverErr; err != nil {
t.Fatalf("fake server error: %v", err)
}
}
type errorCloser struct {
+4 -8
View File
@@ -733,11 +733,7 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
if err != nil {
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
}
resolvedURL := baseURL.ResolveReference(endpointURL)
if !sameMCPOrigin(baseURL, resolvedURL) {
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
}
return resolvedURL.String(), nil
return baseURL.ResolveReference(endpointURL).String(), nil
}
func rpcResponseKey(id any) string {
@@ -877,10 +873,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
// not declare OAuth use the default transport with the same MCP redirect guard.
// not declare OAuth get the default client unchanged.
func oauthHTTPClient(server Server) (*http.Client, error) {
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
return mcpHTTPClient(server, nil), nil
return http.DefaultClient, nil
}
store, err := NewTokenStore(TokenStoreOptions{})
if err != nil {
@@ -892,5 +888,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
httpClient: http.DefaultClient,
now: time.Now,
}
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
}
-136
View File
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -150,141 +149,6 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
}
}
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
source := &fakeTokenSource{}
source.access.Store("access-secret")
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
var targetHits int32
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&targetHits, 1)
if got := r.Header.Get("Authorization"); got != "" {
t.Errorf("redirect target received Authorization = %q", got)
}
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
}))
defer redirector.Close()
client := mcpHTTPClient(
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
)
resp, err := client.Get(redirector.URL + "/mcp")
if resp != nil {
_ = resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
}
if got := atomic.LoadInt32(&targetHits); got != 0 {
t.Fatalf("redirect target hits = %d, want 0", got)
}
}
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
for _, tc := range []struct {
name string
left string
right string
want bool
}{
{
name: "https default port",
left: "https://example.test/mcp",
right: "https://EXAMPLE.test:443/other",
want: true,
},
{
name: "http default port",
left: "http://example.test/mcp",
right: "http://example.test:80/other",
want: true,
},
{
name: "different scheme",
left: "https://example.test/mcp",
right: "http://example.test:443/other",
want: false,
},
{
name: "different port",
left: "https://example.test/mcp",
right: "https://example.test:8443/other",
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
left, err := url.Parse(tc.left)
if err != nil {
t.Fatal(err)
}
right, err := url.Parse(tc.right)
if err != nil {
t.Fatal(err)
}
if got := sameMCPOrigin(left, right); got != tc.want {
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
}
})
}
}
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
for _, tc := range []struct {
name string
base string
endpoint string
want string
wantErr string
}{
{
name: "relative endpoint",
base: "https://example.test/sse",
endpoint: "/messages",
want: "https://example.test/messages",
},
{
name: "absolute same origin",
base: "https://example.test/sse",
endpoint: "https://example.test/messages",
want: "https://example.test/messages",
},
{
name: "default port equivalent",
base: "https://example.test/sse",
endpoint: "https://example.test:443/messages",
want: "https://example.test:443/messages",
},
{
name: "cross origin",
base: "https://example.test/sse",
endpoint: "https://evil.test/messages",
wantErr: "differs from configured server origin",
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
}
if got != tc.want {
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestNonOAuthServerIsUnaffected(t *testing.T) {
// Regression: a server without auth must not get a bearer header or any
// OAuth round tripper, and must connect normally.
-74
View File
@@ -1,74 +0,0 @@
package mcp
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const maxMCPRedirects = 10
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
return &http.Client{
Transport: transport,
CheckRedirect: checkMCPRedirect(server),
}
}
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
return func(request *http.Request, via []*http.Request) error {
if len(via) >= maxMCPRedirects {
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
}
if len(via) == 0 {
return nil
}
if !sameMCPOrigin(via[0].URL, request.URL) {
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
}
return nil
}
}
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectiveMCPPort(left) == effectiveMCPPort(right)
}
func effectiveMCPPort(value *url.URL) string {
if value == nil {
return ""
}
if port := value.Port(); port != "" {
return port
}
switch strings.ToLower(value.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func mcpOrigin(value *url.URL) string {
if value == nil {
return "<nil>"
}
host := value.Hostname()
port := effectiveMCPPort(value)
if port != "" {
host = net.JoinHostPort(host, port)
}
if value.Scheme == "" {
return host
}
return strings.ToLower(value.Scheme) + "://" + host
}
+1 -8
View File
@@ -370,14 +370,7 @@ func TestFullFlowStoresTokens(t *testing.T) {
redirect := parsed.Query().Get("redirect_uri")
go func() {
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
for i := 0; i < 20; i++ {
resp, err := http.Get(cbURL)
if err == nil {
resp.Body.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
_, _ = http.Get(cbURL)
}()
return nil
}
-4
View File
@@ -63,10 +63,6 @@ func DefaultModelEntries() []ModelEntry {
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
}
decorateModelDepth(entries)
// Overlay volatile facts (context limits, base pricing) from a cached
// models.dev snapshot when one is present and fresh — see modelsdev.go.
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
return cloneModelEntries(entries)
}
-269
View File
@@ -1,269 +0,0 @@
package modelregistry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Live models.dev overlay for the curated catalog. The hand-maintained
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
// match patterns, deprecations, escalation targets — but its VOLATILE facts
// (context window, max output tokens, per-million pricing) go stale between
// releases. When a cached snapshot of https://models.dev/api.json is present,
// those fields are refreshed from it at registry construction; everything else
// stays curated. The overlay never adds models (an auto-added entry would lack
// aliases, match patterns, and provider wiring) and never touches the network
// on the registry hot path — fetching happens only in the explicit background
// refresh, cached to disk with a TTL.
const (
modelsDevDefaultURL = "https://models.dev/api.json"
// modelsDevRefreshAfter is how old the cache may get before a background
// refresh re-fetches it.
modelsDevRefreshAfter = 24 * time.Hour
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
// this the curated catalog (updated with the binary) is likely fresher than
// the snapshot, so a stale file is ignored rather than trusted.
modelsDevMaxAge = 7 * 24 * time.Hour
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
modelsDevFetchWindow = 15 * time.Second
)
// modelsDevModel is the subset of a models.dev model record the overlay uses.
type modelsDevModel struct {
Limit struct {
Context int `json:"context"`
Output int `json:"output"`
} `json:"limit"`
Cost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
} `json:"cost"`
}
// modelsDevProvider matches one provider object in api.json.
type modelsDevProvider struct {
Models map[string]modelsDevModel `json:"models"`
}
// parseModelsDev decodes an api.json document into provider-slug -> api-model
// -> record. api.json is a top-level object keyed by provider id.
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
var doc map[string]modelsDevProvider
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
}
providers := make(map[string]map[string]modelsDevModel, len(doc))
for slug, provider := range doc {
if len(provider.Models) == 0 {
continue
}
providers[slug] = provider.Models
}
if len(providers) == 0 {
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
}
return providers, nil
}
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
// worth checking. Only first-party slugs are used: router entries on models.dev
// carry generic or stale numbers.
func modelsDevSlugs(kind ProviderKind) []string {
switch kind {
case ProviderAnthropic:
return []string{"anthropic"}
case ProviderOpenAI:
return []string{"openai"}
case ProviderGoogle:
return []string{"google", "google-vertex"}
default:
return nil
}
}
// applyModelsDevOverrides refreshes each curated entry's context limits and
// base pricing from the snapshot, when the snapshot knows the model. Tiered
// pricing is never touched: models.dev has no tier data, and mixing a live
// base rate with curated tiers would misprice the tier boundaries.
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
if len(providers) == 0 {
return entries
}
for i := range entries {
entry := &entries[i]
var record modelsDevModel
found := false
for _, slug := range modelsDevSlugs(entry.Provider) {
if models, ok := providers[slug]; ok {
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
record = candidate
found = true
break
}
}
}
if !found {
continue
}
if record.Limit.Context > 0 {
entry.ContextLimits.ContextWindow = record.Limit.Context
}
if record.Limit.Output > 0 {
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
}
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
entry.Cost.InputPerMillion = record.Cost.Input
entry.Cost.OutputPerMillion = record.Cost.Output
if record.Cost.CacheRead > 0 {
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
}
if record.Cost.CacheWrite > 0 {
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
}
entry.Cost.Source = "models.dev/api.json (cached)"
}
}
return entries
}
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
// overrides it (used by tests and unusual setups).
func modelsDevCachePath() (string, error) {
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
return override, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "zero", "modelsdev.json"), nil
}
var (
modelsDevOnce sync.Once
modelsDevCached map[string]map[string]modelsDevModel
modelsDevEnabled atomic.Bool
)
// EnableModelsDevOverlay opts the process into applying the cached models.dev
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
// consumers and tests that never do get the curated catalog byte-identical to
// before, so hermetic tests can't be perturbed by a cache file on the machine.
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
func EnableModelsDevOverlay() {
modelsDevEnabled.Store(true)
}
// cachedModelsDevProviders loads the cached snapshot once per process. Not
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
// the curated catalog is used untouched. Read once deliberately:
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
// re-stat the file every time; a background refresh benefits the NEXT process.
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
modelsDevOnce.Do(func() {
path, err := modelsDevCachePath()
if err != nil {
return
}
info, err := os.Stat(path)
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
providers, err := parseModelsDev(data)
if err != nil {
return
}
modelsDevCached = providers
})
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
// current process's registry (see cachedModelsDevProviders). Disabled entirely
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
func RefreshModelsDevCache(ctx context.Context) error {
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
path, err := modelsDevCachePath()
if err != nil {
return err
}
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
return nil
}
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
if url == "" {
url = modelsDevDefaultURL
}
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
defer cancel()
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
if err != nil {
return err
}
// Validate before persisting: a bad body must never clobber a good cache.
if _, err := parseModelsDev(data); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
if err != nil {
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
_ = os.Remove(temp.Name())
return err
}
if err := temp.Close(); err != nil {
_ = os.Remove(temp.Name())
return err
}
return os.Rename(temp.Name(), path)
}
-228
View File
@@ -1,228 +0,0 @@
package modelregistry
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
const sampleModelsDev = `{
"anthropic": {
"id": "anthropic",
"models": {
"claude-sonnet-4-5-20250929": {
"limit": {"context": 1000000, "output": 64000},
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
}
}
},
"google": {
"id": "google",
"models": {
"gemini-2.5-pro": {
"limit": {"context": 2097152, "output": 65536},
"cost": {"input": 9.99, "output": 9.99}
}
}
}
}`
func TestParseModelsDev(t *testing.T) {
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
if !ok {
t.Fatal("expected anthropic sonnet record")
}
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
t.Fatalf("unexpected record: %+v", record)
}
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
t.Fatal("empty document must be rejected")
}
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
t.Fatal("malformed document must be rejected")
}
}
func TestApplyModelsDevOverrides(t *testing.T) {
// Point the cache at a non-existent file so DefaultModelEntries returns the
// pure curated catalog, then apply the sample snapshot explicitly.
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
var sonnet, geminiPro, opus ModelEntry
for _, entry := range entries {
switch entry.ID {
case "claude-sonnet-4.5":
sonnet = entry
case "gemini-2.5-pro":
geminiPro = entry
case "claude-opus-4.1":
opus = entry
}
}
// Known model: limits and base pricing refreshed from the snapshot.
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
}
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
}
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
}
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
// has curated tiers and the snapshot's flat 9.99 would misprice them).
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
}
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
}
// Model absent from the snapshot: untouched.
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
}
}
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(sampleModelsDev))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("expected 1 fetch, got %d", hits)
}
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("cache file missing: %v", err)
}
// Fresh cache: second call must not re-fetch.
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
}
}
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
if err := RefreshModelsDevCache(t.Context()); err == nil {
t.Fatal("bad body must return an error")
}
content, err := os.ReadFile(cachePath)
if err != nil || string(content) != sampleModelsDev {
t.Fatal("bad fetch must not clobber the existing cache")
}
}
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("fetch must not happen when disabled")
}))
defer server.Close()
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("stale cache must be ignored")
}
}
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
// library consumers and hermetic tests get the pure curated catalog.
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("overlay must be opt-in")
}
}
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
for _, entry := range DefaultModelEntries() {
if entry.ID == "claude-sonnet-4.5" {
if entry.ContextLimits.ContextWindow != 1_000_000 {
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
}
return
}
}
t.Fatal("claude-sonnet-4.5 not found")
}
+1 -6
View File
@@ -114,12 +114,7 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too — mirroring acquireFileLock in lock.go —
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
if !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
+5 -16
View File
@@ -44,17 +44,10 @@ type Manager struct {
// ManagerOptions configures a Manager.
type ManagerOptions struct {
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
// AllowPresets opts this manager into the baked-in OAuth presets without the
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
// and CLI login (and the runtime token refresh) for a provider the user chose
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
// flag onto Env (or the process environment when Env is nil), preserving any
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
AllowPresets bool
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
Now func() time.Time
RefreshBuffer time.Duration
Out io.Writer
@@ -90,13 +83,9 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
if open == nil {
open = func(string) error { return nil }
}
env := opts.Env
if opts.AllowPresets {
env = envWithPresetsAllowed(env)
}
return &Manager{
store: opts.Store, registry: registry, client: client,
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
}, nil
}
-24
View File
@@ -52,30 +52,6 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
return m
}
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
// preset provider resolves; without it (and no Env) the env stays nil so callers
// and tests remain hermetic.
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
}
m2, err := NewManager(ManagerOptions{Store: store})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
if m2.env != nil {
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
}
}
func TestManagerLoginLoopback(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
+1 -28
View File
@@ -1,33 +1,6 @@
package oauth
import (
"os"
"strings"
)
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
// login for a well-known public client (e.g. xAI) can use the preset without the
// operator setting the flag themselves. It copies base — or snapshots the process
// environment when base is nil — because envValue treats a non-nil map as hermetic
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
func envWithPresetsAllowed(base map[string]string) map[string]string {
env := make(map[string]string, len(base)+1)
if base == nil {
for _, kv := range os.Environ() {
if eq := strings.IndexByte(kv, '='); eq > 0 {
env[kv[:eq]] = kv[eq+1:]
}
}
} else {
for k, v := range base {
env[k] = v
}
}
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
return env
}
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
-40
View File
@@ -46,46 +46,6 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
}
}
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
// xAI config now resolves from the baked-in preset.
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
r := NewRegistry()
// Baseline: a hermetic empty map keeps the preset inert.
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
t.Fatal("xai should not resolve without the opt-in")
}
// The helper forces the opt-in so the preset resolves.
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
if err != nil {
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
}
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
// overrides — critical because envValue treats a non-nil map as hermetic, so a
// partial map would silently drop them.
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
env := envWithPresetsAllowed(base)
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
t.Fatalf("opt-in flag not set: %v", env)
}
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
}
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig with override env: %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
-20
View File
@@ -41,26 +41,6 @@ func ValidateKey(key string) error {
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
// — the bearer token AND any header claim like chatgpt-account-id — comes from
// the SAME login; selecting independently per consumer could otherwise pair a
// bearer from one login with an account header from another. A load error on a
// candidate is treated as a miss (skip to the next), never a hard failure.
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
if store == nil {
return Token{}, "", false
}
for _, name := range candidates {
key := ProviderKey(name)
if token, ok, err := store.Load(key); err == nil && ok {
return token, key, true
}
}
return Token{}, "", false
}
// Status is a redaction-safe summary of a stored token (no secret material).
type Status struct {
Key string `json:"key"`
+1 -7
View File
@@ -123,10 +123,8 @@ var descriptors = []Descriptor{
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
@@ -135,11 +133,7 @@ var descriptors = []Descriptor{
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
+2 -30
View File
@@ -23,10 +23,8 @@ var expectedCatalogIDs = []string{
"together",
"dashscope",
"moonshot",
"longcat",
"nvidia-nim",
"minimax",
"minimaxi-cn",
"mistral",
"github",
"bedrock",
@@ -36,10 +34,6 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"zai-cn",
"kilocode",
"opencode",
"opencode-go",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -102,28 +96,6 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestLongCatDescriptor(t *testing.T) {
descriptor, err := Require("longcat")
if err != nil {
t.Fatalf("Require(longcat) error = %v", err)
}
if descriptor.Name != "LongCat" {
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "LongCat-2.0" {
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
}
}
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
for _, descriptor := range All() {
if descriptor.ID == "" {
@@ -291,8 +263,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportGoogle: {"google"},
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
if err := validateEndpoint(ctx, ep, r, true); err != nil {
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
if !endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
}
}
@@ -179,27 +179,14 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
}
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
// link-local and other special ranges (cloud metadata, documentation) stay
// blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
// link-local, private) stay blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
}
if endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
}
}
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
// now allowed when the flag is true — the user is pointing at their own
// LAN box (e.g. llama.cpp on another local machine).
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
if endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
}
}
}
+25 -27
View File
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
if err != nil {
category := CategoryConfig
var safety endpointSafetyError
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}
client := options.HTTPClient
if client == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
}
response, err := client.Do(request)
if err != nil {
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
allowLoopback := endpointIsLoopback(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
return nil, false, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
request.Header.Set("User-Agent", options.UserAgent)
}
applyAuth(request, profile, kind)
return request, allowLoopbackOrPrivate, nil
return request, allowLoopback, nil
}
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
}
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
// is set true exclusively for the user's own explicitly-configured base_url
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
// set true exclusively for the user's own explicitly-configured local provider
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
}
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
if allowLoopbackOrPrivate {
if allowLoopback {
return nil // user-configured local provider; loopback is intentional
}
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -407,19 +407,17 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
return nil
}
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
// or private-network host the user typed themselves — a local provider on the LAN
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
// connectivity probe may relax address-blocking and reach the user's own
// explicitly-configured provider.
func endpointIsLoopbackOrPrivate(endpoint string) bool {
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
// decide whether the connectivity probe may reach the user's own local provider.
func endpointIsLoopback(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
@@ -429,7 +427,7 @@ func endpointIsLoopbackOrPrivate(endpoint string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback() || addr.IsPrivate()
return addr.IsLoopback()
}
return false
}
@@ -448,7 +446,7 @@ const maxConnectivityRedirects = 5
// time and then dials the validated IP literal, closing the TOCTOU window
// between the pre-flight validateEndpoint check and the actual connection
// (DNS rebinding).
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
if resolver == nil {
resolver = defaultResolver{}
}
@@ -458,7 +456,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
} else {
transport = &http.Transport{}
}
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
transport.DialContext = safeDialContext(resolver, allowLoopback)
return &http.Client{
Timeout: timeout,
Transport: transport,
@@ -478,8 +476,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
}
}
// Redirects are NEVER allowed to reach loopback, even for a local provider:
// allowLoopbackOrPrivate covers only the user's own configured base_url,
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
// allowLoopback covers only the user's own configured base_url, not a 3xx
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
},
}
@@ -521,7 +519,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
// safeDialContext returns a dial function that resolves the target host, refuses
// the connection if any resolved address is blocked, then dials the validated IP
// literal so the kernel cannot re-resolve to a different address after the check.
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
@@ -529,7 +527,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -542,7 +540,7 @@ func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(contex
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
+10 -20
View File
@@ -21,22 +21,6 @@ type Model struct {
Source string
}
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
// per call, so the shared backing slice cannot be mutated by callers.
var minimaxCuratedModels = []Model{
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
}
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
// open.bigmodel.cn); same model lineup on both endpoints.
var zaiCuratedModels = []Model{
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
}
var curatedModels = map[string][]Model{
"ollama-cloud": {
{ID: "qwen3-coder:480b", Description: "catalog default"},
@@ -104,8 +88,10 @@ var curatedModels = map[string][]Model{
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
},
"minimax": minimaxCuratedModels,
"minimaxi-cn": minimaxCuratedModels,
"minimax": {
{ID: "MiniMax-M3", Description: "catalog default"},
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
},
"mistral": {
{ID: "mistral-large-latest", Description: "catalog default"},
{ID: "codestral-latest", Description: "coding model"},
@@ -137,8 +123,12 @@ var curatedModels = map[string][]Model{
"bankr": {
{ID: "bankr-large", Description: "catalog default"},
},
"zai": zaiCuratedModels,
"zai-cn": zaiCuratedModels,
"zai": {
{ID: "glm-4.5", Description: "catalog default"},
{ID: "glm-4.5-air", Description: "fast model"},
{ID: "glm-4.6", Description: "latest general model"},
{ID: "glm-z1-air", Description: "reasoning model"},
},
// OpenGateway smart-routes by model id across its upstream providers
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
// the curated coding defaults; the gateway accepts any model its upstreams
@@ -37,16 +37,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
want: []string{"mistral-large-latest", "codestral-latest"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "minimaxi-cn",
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
{
provider: "zai-cn",
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
},
}
for _, tt := range tests {
@@ -98,66 +88,3 @@ func modelIDs(models []Model) []string {
}
return ids
}
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
// international provider; a divergence here is almost always a mistake.
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("minimax")
if !ok {
t.Fatal("provider minimax missing from catalog")
}
cn, ok := providercatalog.Get("minimaxi-cn")
if !ok {
t.Fatal("provider minimaxi-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
// same model lineup.
func TestZaiCNModelsMirrorInternational(t *testing.T) {
intl, ok := providercatalog.Get("zai")
if !ok {
t.Fatal("provider zai missing from catalog")
}
cn, ok := providercatalog.Get("zai-cn")
if !ok {
t.Fatal("provider zai-cn missing from catalog")
}
intlModels := Models(intl)
cnModels := Models(cn)
if len(intlModels) != len(cnModels) {
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
}
intlIDs := map[string]string{}
for _, m := range intlModels {
intlIDs[m.ID] = m.Description
}
for _, m := range cnModels {
wantDesc, ok := intlIDs[m.ID]
if !ok {
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
}
if m.Description != wantDesc {
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
}
}
}
-4
View File
@@ -128,10 +128,6 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
return "nvidia"
case "xiaomi-mimo":
return "xiaomi"
case "zai-cn":
return "zai"
case "minimaxi-cn":
return "minimax"
default:
return strings.TrimSpace(provider.ID)
}
@@ -118,8 +118,6 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
"xiaomi-mimo": "xiaomi",
"dashscope": "alibaba",
"ollama-cloud": "ollama-cloud",
"zai-cn": "zai",
"minimaxi-cn": "minimax",
}
for zeroID, want := range tests {
provider, ok := providercatalog.Get(zeroID)
@@ -85,104 +85,6 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
}
}
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
// length via its native /api/show endpoint. The generic /v1/models probe
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
// listings don't carry context-window metadata, and a custom/local model tag
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
// through the local daemon) has no curated-catalog entry to borrow one from
// either, so modelContextWindow has nothing to show for it without this. Only
// meaningful for the local Ollama provider (baseURL like
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
// service and isn't assumed to expose the same endpoint.
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
model = strings.TrimSpace(model)
if model == "" {
return 0, fmt.Errorf("model name is required")
}
endpoint, err := ollamaShowEndpoint(baseURL)
if err != nil {
return 0, err
}
payload, err := json.Marshal(struct {
Model string `json:"model"`
}{Model: model})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
client := options.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return 0, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return parseOllamaShowContextWindow(body)
}
// ollamaShowEndpoint derives the native Ollama API root from the
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
// appends /api/show.
func ollamaShowEndpoint(baseURL string) (string, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
}
path := strings.TrimRight(parsed.Path, "/")
path = strings.TrimSuffix(path, "/v1")
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
// parseOllamaShowContextWindow extracts the context length from an
// /api/show response. Ollama reports it under model_info with an
// architecture-prefixed key (e.g. "llama.context_length",
// "qwen2.context_length") — the prefix varies by model family, so this scans
// for any key ending in ".context_length" rather than hardcoding one.
func parseOllamaShowContextWindow(body []byte) (int, error) {
// model_info mixes value types (strings like "general.architecture", numbers
// like "*.context_length"), so decode values generically and only try to
// read a number out of the one key we actually care about.
var payload struct {
ModelInfo map[string]any `json:"model_info"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 0, fmt.Errorf("decode ollama show response: %w", err)
}
for key, value := range payload.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
if window, ok := value.(float64); ok && window > 0 {
return int(window), nil
}
}
return 0, fmt.Errorf("ollama show response did not report a context length")
}
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
endpoint, err := modelsEndpoint(profile.BaseURL)
if err != nil {
@@ -2,7 +2,6 @@ package providermodeldiscovery
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -268,61 +267,3 @@ func modelIDs(models []Model) []string {
}
return ids
}
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
// /v1/models probe never carries context-window metadata (parseModelsResponse
// only extracts id/description), so a custom/local Ollama model tag with no
// curated-catalog match has no other source for it. This exercises the
// Ollama-native /api/show fallback that fills that gap.
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
var gotPath, gotMethod, gotBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model_info": {
"general.architecture": "qwen2",
"qwen2.context_length": 131072
}
}`))
}))
defer server.Close()
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
}
if window != 131072 {
t.Fatalf("context window = %d, want 131072", window)
}
if gotPath != "/api/show" {
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
}
if gotMethod != http.MethodPost {
t.Fatalf("method = %q, want POST", gotMethod)
}
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
t.Fatalf("request body = %q, want it to name the model", gotBody)
}
}
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
t.Fatal("expected an error for an empty model name")
}
}
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
}))
defer server.Close()
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
t.Fatal("expected an error when no *.context_length key is present")
}
}
@@ -1,96 +0,0 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// The final two messages must carry a cache_control breakpoint on their last
// cacheable block so the conversation transcript — not just system prompt and
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
// these two).
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "first"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "calling a tool",
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
},
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
}
lastBlockHasCache := func(message any) bool {
blocks, ok := message.(map[string]any)["content"].([]any)
if !ok || len(blocks) == 0 {
return false
}
last := blocks[len(blocks)-1].(map[string]any)
control, ok := last["cache_control"].(map[string]any)
return ok && control["type"] == "ephemeral"
}
if lastBlockHasCache(messages[0]) {
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
}
if !lastBlockHasCache(messages[1]) {
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
}
if !lastBlockHasCache(messages[2]) {
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
}
}
// A string-content message must be converted to a block array so it can carry
// the breakpoint, and thinking blocks must never carry cache_control (the API
// rejects them) — the marker goes on the last cacheable block instead.
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
messages := []anthropicMessage{
{Role: "user", Content: "plain string"},
{Role: "assistant", Content: []map[string]any{
{"type": "text", "text": "answer"},
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
}},
}
applyMessageCacheBreakpoints(messages)
userBlocks := contentBlocks(messages[0].Content)
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
}
assistantBlocks := contentBlocks(messages[1].Content)
if assistantBlocks[1]["cache_control"] != nil {
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
}
if assistantBlocks[0]["cache_control"] == nil {
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
}
}
+2 -28
View File
@@ -211,11 +211,11 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
@@ -405,35 +405,9 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
}
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
}
applyMessageCacheBreakpoints(mapped.Messages)
return mapped, nil
}
// applyMessageCacheBreakpoints marks the last content block of the final two
// messages with cache_control so the conversation transcript is cached
// turn-over-turn, not just the system prompt and tool definitions. Two message
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
// request: system + tools use two above, these use the remaining two. Thinking
// blocks cannot carry cache_control, so the marker goes on the last block that
// can.
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
marked := 0
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
blocks := contentBlocks(messages[i].Content)
for j := len(blocks) - 1; j >= 0; j-- {
blockType, _ := blocks[j]["type"].(string)
if blockType == "thinking" || blockType == "redacted_thinking" {
continue
}
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
messages[i].Content = blocks
marked++
break
}
}
}
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
systemParts := []string{}
mapped := []anthropicMessage{}
+13 -31
View File
@@ -26,13 +26,6 @@ type Options struct {
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
// account from the exact login that issued the bearer token — never a second,
// independent lookup that could pick a different login (a backend-rejected
// mismatch).
OAuthLoginKey string
}
// New creates a runtime provider for a resolved provider profile.
@@ -258,22 +251,14 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
// the stored OAuth token's Account field on every request so a refresh that
// rotates the token (and its account claim) takes effect immediately — a static
// AccountID captured at construction would go stale on the first refresh.
//
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
// refreshes the token in place under that key; reading the account from the same
// key keeps header and token on one login for the provider's life. Resolving the
// key independently here (a second oauth.FirstStored) could, after a transient
// per-candidate load error or a mid-session `zero auth login`, pick a different
// login than the bearer — a mismatch the backend rejects.
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
// resolved dynamically from the stored OAuth token's Account field on
// every request so a refresh that rotates the token (and its account
// claim) takes effect immediately — a static AccountID captured at
// construction would go stale on the first refresh.
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
accountKey := options.OAuthLoginKey
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
account := codexAccountForKey(accountKey)
account := codexAccountFromStore(profile.Name)
if account == "" {
return "", false, nil
}
@@ -303,20 +288,17 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
})
}
// codexAccountForKey reads the chatgpt_account_id from the token currently
// stored under key. Called per-request (via the resolver) so a refresh that
// rotates the token — saved back in place under the same key — takes effect
// immediately without restarting the agent. Returns "" when key is empty (no
// OAuth login), or the token is absent or carries no account claim.
func codexAccountForKey(key string) string {
if key == "" {
return ""
}
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
// token for the given provider name. It is called per-request (via the
// resolver) so a refresh that rotates the token takes effect immediately
// without restarting the agent. Returns "" when no token is stored (the
// Codex provider then omits the header and the user sees a clear 401).
func codexAccountFromStore(providerName string) string {
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return ""
}
token, ok, err := store.Load(key)
token, ok, err := store.Load(oauth.ProviderKey(providerName))
if err != nil || !ok {
return ""
}
+12 -71
View File
@@ -264,14 +264,6 @@ func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
}
func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// Isolate the OAuth token store to an empty temp path so the factory reads no
// stored login — otherwise this test picks up the developer's real chatgpt
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
@@ -323,10 +315,11 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
// The factory reads the stored OAuth token's Account field for the
// chatgpt-account-id header, from the login key the CALLER supplies in
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
// token in an isolated temp store, then pass that key.
store, err := newOAuthStoreForTest(t)
// chatgpt-account-id header. Seed a token in a temp store and point
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
dir := t.TempDir()
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
store, err := newOAuthStoreForTest()
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -344,9 +337,8 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
CatalogID: "chatgpt",
Model: "gpt-5",
}, Options{
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
HTTPClient: &http.Client{Transport: transport},
UserAgent: "zero-factory-test",
})
if err != nil {
t.Fatalf("New() error = %v", err)
@@ -408,61 +400,10 @@ func (transport *captureTransport) body() io.Reader {
return strings.NewReader(transport.requestBody)
}
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
// path: an inherited "keyring" value would send NewStore to the OS keychain
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
// developer's real logins. Exists so the chatgpt factory tests can seed a
// token without copying the path-handling dance from internal/cli.
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
t.Helper()
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
// newOAuthStoreForTest builds a Store pointed at the current
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
// factory tests can seed a token without copying the path-handling dance
// from internal/cli.
func newOAuthStoreForTest() (*oauth.Store, error) {
return oauth.NewStore(oauth.StoreOptions{})
}
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
// therefore always read from the same login that issued the bearer (no second,
// independent selection). It re-reads per call, so an in-place token refresh (new
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
// or a missing/account-less token yields "".
func TestCodexAccountForKey(t *testing.T) {
store, err := newOAuthStoreForTest(t)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
t.Fatalf("Save chatgpt: %v", err)
}
// A different login WITHOUT an account claim, to prove the read stays on the
// requested key and never crosses to another login's account.
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
t.Fatalf("Save codex: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
t.Fatalf("account = %q, want acc-catalog-7", got)
}
// The bound key's token has no account → "", NOT the other login's account.
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
}
// Empty key (no OAuth login) → header omitted.
if got := codexAccountForKey(""); got != "" {
t.Fatalf("account for empty key = %q, want empty", got)
}
// Unknown key → "".
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
t.Fatalf("account for unknown key = %q, want empty", got)
}
// An in-place refresh under the bound key IS reflected per request.
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
t.Fatalf("refresh chatgpt: %v", err)
}
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
}
}
+3 -68
View File
@@ -158,10 +158,10 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, &state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
declarations = append(declarations, geminiFunctionDeclaration{
Name: tool.Name,
Description: tool.Description,
Parameters: sanitizeGeminiSchema(tool.Parameters),
Parameters: tool.Parameters,
})
}
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
@@ -350,71 +350,6 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
return mapped, nil
}
// geminiSchemaFields is the subset of JSON Schema keywords Google's
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
// \"additionalProperties\" … Cannot find field") — must be dropped before the
// request goes out. Kept as an allowlist rather than a denylist so a schema
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
// field into the Gemini payload.
var geminiSchemaFields = map[string]bool{
"type": true, "format": true, "title": true, "description": true,
"nullable": true, "enum": true, "items": true, "properties": true,
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
"minLength": true, "maxLength": true, "minProperties": true,
"maxProperties": true, "pattern": true, "example": true,
}
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
// so a parameterless tool stays parameterless.
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
out := make(map[string]any, len(schema))
for key, value := range schema {
if !geminiSchemaFields[key] {
continue
}
switch key {
case "properties":
if props, ok := value.(map[string]any); ok {
cleaned := make(map[string]any, len(props))
for name, sub := range props {
if subMap, ok := sub.(map[string]any); ok {
cleaned[name] = sanitizeGeminiSchema(subMap)
} else {
cleaned[name] = sub
}
}
value = cleaned
}
case "items":
if subMap, ok := value.(map[string]any); ok {
value = sanitizeGeminiSchema(subMap)
}
case "anyOf":
if variants, ok := value.([]any); ok {
cleaned := make([]any, len(variants))
for i, variant := range variants {
if subMap, ok := variant.(map[string]any); ok {
cleaned[i] = sanitizeGeminiSchema(subMap)
} else {
cleaned[i] = variant
}
}
value = cleaned
}
}
out[key] = value
}
return out
}
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
systemParts := []geminiPart{}
contents := []geminiContent{}
-127
View File
@@ -3,7 +3,6 @@ package gemini
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
@@ -505,129 +504,3 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
}
return matching
}
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
// depth while legitimate fields (type/description/enum/required/default) stay.
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
in := map[string]any{
"type": "object",
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": []string{"path"},
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "a path",
"default": ".",
},
"nested": map[string]any{
"type": "object",
"additionalProperties": false, // must be stripped at depth too
"properties": map[string]any{
"tags": map[string]any{
"type": "array",
"items": map[string]any{"type": "string", "additionalProperties": true},
},
},
},
},
}
out := sanitizeGeminiSchema(in)
assertNoAdditionalProps(t, out, "root")
if out["additionalProperties"] != nil {
t.Fatal("top-level additionalProperties must be stripped")
}
if out["$schema"] != nil {
t.Fatal("$schema must be stripped")
}
// Legitimate fields survive.
if out["type"] != "object" {
t.Fatalf("type should survive, got %v", out["type"])
}
props := out["properties"].(map[string]any)
pathProp := props["path"].(map[string]any)
if pathProp["description"] != "a path" || pathProp["default"] != "." {
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
}
// nil input stays nil (parameterless tool).
if sanitizeGeminiSchema(nil) != nil {
t.Fatal("nil schema should map to nil")
}
}
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
// (recursively walked) schema.
func assertNoAdditionalProps(t *testing.T, node any, path string) {
t.Helper()
switch v := node.(type) {
case map[string]any:
if _, ok := v["additionalProperties"]; ok {
t.Fatalf("additionalProperties leaked at %s", path)
}
for k, sub := range v {
assertNoAdditionalProps(t, sub, path+"."+k)
}
case []any:
for i, sub := range v {
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
}
}
}
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
// against Google (issue #373).
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
}))
defer server.Close()
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
Tools: []zeroruntime.ToolDefinition{{
Name: "grep",
Description: "search",
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
"type": "object",
"additionalProperties": false,
"required": []string{"pattern"},
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "regex"},
},
},
}},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
tools := gotBody["tools"].([]any)
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
params := decls[0].(map[string]any)["parameters"].(map[string]any)
if _, ok := params["additionalProperties"]; ok {
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
}
// The tool is still usable: its real schema survived.
if params["type"] != "object" {
t.Fatalf("tool parameters type should survive, got %v", params["type"])
}
props := params["properties"].(map[string]any)
if _, ok := props["pattern"]; !ok {
t.Fatalf("tool property must survive sanitization: %#v", props)
}
}
+2 -2
View File
@@ -389,10 +389,10 @@ func (p *CodexProvider) streamResponses(
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, inner.streamIdleTimeout, func(data string) bool {
return p.emitResponsesEvent(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: p.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, inner.streamIdleTimeout)),
Error: p.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", inner.streamIdleTimeout)),
})
return
}
+6 -7
View File
@@ -102,11 +102,10 @@ func New(options Options) (*Provider, error) {
endpoint = baseURL + "/chat/completions"
}
// Route through providerio.HTTPClient so a missing client gets the shared,
// stall-hardened transport (bounded response-header wait + shorter idle-conn
// reuse) rather than the raw http.DefaultClient, which hangs on a reused-dead
// pooled connection (the macOS-only "both providers stall" bug).
httpClient := providerio.HTTPClient(options.HTTPClient)
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
maxTokens := options.MaxTokens
if maxTokens < 0 {
@@ -219,12 +218,12 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
if errors.Is(err, providerio.ErrStreamIdle) {
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
})
return
}
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more