mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533b41495d | ||
|
|
bb66a44ab3 | ||
|
|
6f97c90316 | ||
|
|
f401c66e81 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb | ||
|
|
b63fe696fd | ||
|
|
3fbbd28e4c | ||
|
|
b9ddd6f421 | ||
|
|
aaad4d2712 | ||
|
|
b4275e3504 | ||
|
|
5b2b4dea1a | ||
|
|
b5a78dca6f | ||
|
|
952788f72d | ||
|
|
f915f70e5a | ||
|
|
cdf9d839ae | ||
|
|
960db9660e | ||
|
|
b1ccb6d9c1 | ||
|
|
0bc8074c97 | ||
|
|
72eed06b4f | ||
|
|
33dc7ae2cc | ||
|
|
292e1edea5 | ||
|
|
39e7100674 | ||
|
|
027a8f2768 | ||
|
|
bd89a1f451 | ||
|
|
c807f92b72 | ||
|
|
8eccc26698 | ||
|
|
6e0a665118 | ||
|
|
142c548c89 | ||
|
|
02f0c0929b | ||
|
|
3fde03a900 | ||
|
|
85273ea72d | ||
|
|
0914ced9e1 | ||
|
|
68669073c2 | ||
|
|
33410d8730 | ||
|
|
c0254e3266 | ||
|
|
c4c14d62d4 | ||
|
|
49bec0274f | ||
|
|
bcc65da2a0 | ||
|
|
2a8802e39c |
No files matched your search
@@ -54,8 +54,10 @@ 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: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
run: |
|
||||
pkg_version="$(node -p "require('./package.json').version")"
|
||||
@@ -78,13 +80,15 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# 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.
|
||||
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
|
||||
# release-please workflow), publish the packaged archive + .sha256 as GitHub
|
||||
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
|
||||
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
|
||||
# action dependency); each matrix OS creates the release if absent
|
||||
# (best-effort — release-please normally creates it first) then uploads its
|
||||
# own assets with --clobber. Tags come from merging the release-please PR.
|
||||
- name: Publish to GitHub Release
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -92,3 +96,89 @@ 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
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write # push the release-PR branch, create the tag + GitHub Release
|
||||
pull-requests: write # open/update the release PR
|
||||
issues: write # create/apply release-tracking labels on the release PR
|
||||
actions: write # dispatch release-artifacts.yml once a release is cut
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release PR / tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
|
||||
|
||||
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
|
||||
# workflows (GitHub suppresses workflows triggered by workflow-created
|
||||
# events), so release-artifacts.yml would not run for a release-please
|
||||
# tag. workflow_dispatch invoked through the API is exempt from that
|
||||
# suppression — dispatch the artifacts build at the new tag explicitly.
|
||||
- name: Build and publish release artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release-artifacts.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "${{ steps.release.outputs.tag_name }}"
|
||||
@@ -4,12 +4,14 @@ 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:
|
||||
@@ -29,7 +31,8 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -65,6 +68,19 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{".":"0.1.0"}
|
||||
@@ -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` (uppercase `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` — 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.
|
||||
|
||||
+21
-1
@@ -6,12 +6,32 @@ 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.
|
||||
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<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,
|
||||
@@ -51,6 +53,31 @@ 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:
|
||||
@@ -121,6 +148,13 @@ 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
|
||||
@@ -254,9 +288,9 @@ zero update check for newer releases
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `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_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<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) 发布。
|
||||
+4
-5
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported into the step environment and never written to the log.
|
||||
it is exported only for the ZERO step 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 for the run. Leave the action
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -192,10 +192,9 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# 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 in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
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}"
|
||||
|
||||
+21
-1
@@ -54,8 +54,28 @@ 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. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
'[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+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ 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 for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
> 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`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -51,8 +52,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; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
@@ -162,9 +163,10 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`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.
|
||||
`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.
|
||||
- **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
|
||||
|
||||
@@ -24,6 +24,51 @@ 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:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@ 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.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// 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
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// 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
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
|
||||
+155
-81
@@ -210,27 +210,29 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapped callbacks flag ANY output forwarded this turn, so a stall retry
|
||||
// never re-streams on top of output the user already saw — even across the
|
||||
// reactive-compaction retry below, which overwrites `collected`.
|
||||
forwardedAnything := false
|
||||
mark := func() { forwardedAnything = true }
|
||||
// OnUsage is telemetry, not visible output, so it does NOT mark
|
||||
// forwardedAnything: a provider that emits an early usage event (e.g. prompt
|
||||
// tokens) then stalls with no content must still be safely retried. The gate
|
||||
// only guards against re-streaming output the user actually SAW.
|
||||
// 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) { mark(); options.OnText(s) }
|
||||
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
|
||||
}
|
||||
if options.OnReasoning != nil {
|
||||
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
|
||||
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
|
||||
}
|
||||
if options.OnToolCallStart != nil {
|
||||
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
|
||||
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
|
||||
}
|
||||
if options.OnToolCallDelta != nil {
|
||||
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); options.OnToolCallDelta(id, fragment) }
|
||||
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)
|
||||
@@ -284,15 +286,28 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout with NO output forwarded yet this turn can be
|
||||
// safely re-issued on a fresh connection — nothing was shown, so there's no
|
||||
// duplication (this recovers the macOS stale-pooled-connection hang when it
|
||||
// slips past the transport's response-header timeout). A turn that already
|
||||
// streamed partial text/tool-calls is NOT retried (it would duplicate) and
|
||||
// falls through to the error return below. Capped + exponential backoff.
|
||||
// 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) && !forwardedAnything &&
|
||||
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
|
||||
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
|
||||
@@ -801,6 +816,25 @@ 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().
|
||||
@@ -2144,6 +2178,18 @@ 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
|
||||
}
|
||||
@@ -2295,7 +2341,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
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)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2310,7 +2361,9 @@ 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 requested permission in network or file_system")
|
||||
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"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2443,76 +2496,97 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// 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)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
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)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
eager = append(eager, runtimeToolDefinition(tool))
|
||||
}
|
||||
|
||||
// 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 active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
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
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if 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
|
||||
}
|
||||
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()),
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,35 @@ 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
|
||||
}
|
||||
@@ -865,7 +894,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
@@ -896,6 +925,52 @@ func TestRunAdvertisesAllowedWebSearchInAutoMode(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()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,25 @@ import (
|
||||
)
|
||||
|
||||
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
|
||||
// timeout error for the first stallBefore calls, then succeeds with "done". When
|
||||
// partialText is set it emits that text BEFORE the stall, simulating a stall after
|
||||
// partial output (which must NOT be retried — re-issuing would duplicate it).
|
||||
// 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
|
||||
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, 3)
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
if n <= p.stallBefore {
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
@@ -30,9 +36,16 @@ func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.Comple
|
||||
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 10m (the model produced nothing)",
|
||||
Error: "provider stream error: no output for 6m (the model produced nothing)",
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
@@ -72,21 +85,54 @@ func TestRunDoesNotRetryStallAfterPartialOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A stall after only REASONING was forwarded (no text/tool-calls) must NOT be
|
||||
// retried: the user already saw the reasoning, so re-issuing with callbacks
|
||||
// re-enabled would duplicate it. collected.Text is empty here, so this exercises
|
||||
// the turn-level forwarded-output gate specifically, not the text check.
|
||||
func TestRunDoesNotRetryStallAfterForwardedReasoning(t *testing.T) {
|
||||
// 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"}
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(string) {},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
|
||||
if err != nil {
|
||||
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -86,6 +87,9 @@ 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)
|
||||
}
|
||||
@@ -164,6 +168,64 @@ 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)
|
||||
@@ -310,9 +372,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 (uppercase 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 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 {
|
||||
|
||||
@@ -46,14 +46,10 @@ 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.** 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.
|
||||
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.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,9 @@ 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.
|
||||
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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ 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 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).
|
||||
// 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).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ 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 ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,9 +29,11 @@ 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, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.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 {
|
||||
|
||||
@@ -174,6 +174,16 @@ 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
|
||||
@@ -189,6 +199,12 @@ 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
|
||||
|
||||
@@ -455,11 +455,15 @@ 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.
|
||||
// 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.
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
if !agentReached {
|
||||
|
||||
+5
-2
@@ -43,8 +43,11 @@ 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,
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
|
||||
+159
-58
@@ -22,6 +22,7 @@ import (
|
||||
"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"
|
||||
@@ -43,37 +44,44 @@ 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)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -99,9 +107,10 @@ 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,
|
||||
getwd: os.Getwd,
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
@@ -118,13 +127,19 @@ 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: oauthResolverForProfile(profile),
|
||||
OAuthResolver: resolver,
|
||||
OAuthLoginKey: loginKey,
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -166,6 +181,7 @@ func defaultAppDeps() appDeps {
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -217,9 +233,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --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 <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -369,6 +385,8 @@ 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":
|
||||
@@ -391,6 +409,14 @@ 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
|
||||
}
|
||||
@@ -421,6 +447,9 @@ 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
|
||||
}
|
||||
@@ -490,9 +519,23 @@ 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
|
||||
}
|
||||
|
||||
@@ -508,7 +551,32 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -641,6 +709,7 @@ 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,
|
||||
@@ -689,9 +758,11 @@ 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,
|
||||
@@ -719,20 +790,50 @@ 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
|
||||
}
|
||||
// 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 {
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
|
||||
// (and every other newProvider caller) needs no per-site key handling.
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pin spawned children (sub-agents / swarm members inherit the environment) to
|
||||
// THIS run's provider from launch, not only after an in-session switch. Without
|
||||
// the launch-time export a child re-resolves config.json at spawn time, so a
|
||||
// provider switch persisted by ANOTHER zero process mid-session would silently
|
||||
// move new children onto a different provider (and different credentials) than
|
||||
// the parent is running. Runtime switches (/model, /provider, wizard,
|
||||
// onboarding) re-export on commit, keeping the pin current. Injected via deps
|
||||
// (nil in tests) because it mutates the process environment.
|
||||
if deps.exportActiveProvider != nil {
|
||||
deps.exportActiveProvider(resolved.Provider.Name)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
|
||||
// when the resolver left it empty (the resolver is pure — no I/O — so an
|
||||
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
|
||||
// providers and when nothing is stored. This is applied once, centrally, by the
|
||||
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
|
||||
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
|
||||
// and any future caller) is covered without a per-site invariant to forget.
|
||||
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
|
||||
if userConfigPath == nil {
|
||||
return profile
|
||||
}
|
||||
if path, perr := userConfigPath(); perr == nil {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
return deps.newProvider(profile)
|
||||
return profile
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
@@ -1033,9 +1134,10 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
// a silent fallback.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
@@ -1043,7 +1145,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, dark, or light)")
|
||||
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
|
||||
}
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = args[2:]
|
||||
@@ -1053,12 +1155,10 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
default:
|
||||
return theme, args, nil
|
||||
}
|
||||
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)
|
||||
if !tui.ValidThemeArg(value) {
|
||||
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
|
||||
}
|
||||
theme = strings.ToLower(value)
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
@@ -1078,7 +1178,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||
// provider like Ollama/LM Studio that needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
@@ -1093,7 +1194,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+356
-1
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -157,6 +158,141 @@ 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
|
||||
@@ -1192,6 +1328,153 @@ 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()
|
||||
|
||||
@@ -1397,7 +1680,79 @@ 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, dark, or light") {
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||
// error escaped before the wizard could open). The interactive TUI now treats
|
||||
// the requires-model failure as "needs onboarding", same as a missing active
|
||||
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launched := false
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||
// users try them (reported in the wild), so the unknown-command error points at
|
||||
// the real command.
|
||||
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||
}
|
||||
// An unrelated unknown command gets no misleading suggestion.
|
||||
stderr.Reset()
|
||||
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||
if strings.Contains(stderr.String(), "did you mean") {
|
||||
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -283,6 +283,9 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -298,12 +301,27 @@ 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: parsed.positional[0],
|
||||
Provider: provider,
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -486,8 +504,9 @@ 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. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
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_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||
}
|
||||
// Case-insensitive provider name should still route.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||
// scopes are rejected up front rather than plumbed through.
|
||||
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ 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))
|
||||
}
|
||||
@@ -465,9 +468,11 @@ 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
|
||||
|
||||
@@ -365,6 +365,9 @@ 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)
|
||||
@@ -505,6 +508,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
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,
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -45,3 +46,88 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -12,42 +11,48 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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, ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil
|
||||
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
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
// No login under any candidate (or unreadable/invalid keys) → 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, ""
|
||||
}
|
||||
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
@@ -64,4 +69,5 @@ func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenRes
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
}
|
||||
@@ -3,7 +3,9 @@ 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"
|
||||
@@ -57,6 +59,27 @@ 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,6 +18,7 @@ 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"},
|
||||
}
|
||||
@@ -46,6 +47,21 @@ 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")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
}
|
||||
|
||||
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
||||
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
return profile.HasConfiguredCredential()
|
||||
}
|
||||
|
||||
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
|
||||
|
||||
+13
-3
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -314,10 +315,14 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
}
|
||||
|
||||
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
|
||||
// provider, keyed by its profile name or catalog id.
|
||||
// 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).
|
||||
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
||||
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
|
||||
if name != "" && oauthLogins[name] {
|
||||
for _, name := range profile.OAuthLoginCandidates() {
|
||||
if oauthLogins[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -449,6 +454,11 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestRunSpecialistListShowAndPath(t *testing.T) {
|
||||
@@ -326,7 +328,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
configRoot, err := os.UserConfigDir()
|
||||
configRoot, err := config.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
|
||||
+51
-8
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type updateOptions struct {
|
||||
check bool
|
||||
apply bool
|
||||
json bool
|
||||
repository string
|
||||
endpoint string
|
||||
@@ -20,6 +21,18 @@ 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())
|
||||
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.check {
|
||||
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
|
||||
if options.check && options.apply {
|
||||
return writeUsageError(stderr, "Pass only one of --check or --apply.")
|
||||
}
|
||||
checkOptions := update.Options{
|
||||
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{
|
||||
CurrentVersion: version,
|
||||
Repository: options.repository,
|
||||
Endpoint: options.endpoint,
|
||||
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
}
|
||||
checkOptions.GOOS = target.GOOS
|
||||
checkOptions.GOARCH = target.GOARCH
|
||||
updateOptions.GOOS = target.GOOS
|
||||
updateOptions.GOARCH = target.GOARCH
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), checkOptions)
|
||||
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)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
||||
}
|
||||
@@ -72,6 +110,8 @@ 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":
|
||||
@@ -155,14 +195,17 @@ 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
|
||||
--json Print the update check result as JSON
|
||||
--apply Download, verify, and install the latest release
|
||||
--json Print the update 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 (for example windows-x64)
|
||||
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -662,3 +662,144 @@ 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,13 +6,16 @@ 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 {
|
||||
@@ -35,8 +38,10 @@ 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 {
|
||||
@@ -198,9 +203,52 @@ 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: options.message,
|
||||
Message: message,
|
||||
DryRun: options.dryRun,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
@@ -395,9 +443,11 @@ 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 {
|
||||
@@ -415,14 +465,19 @@ 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.message != "" || options.dryRun) {
|
||||
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
|
||||
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.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
@@ -712,9 +767,47 @@ 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
|
||||
}
|
||||
@@ -155,6 +155,63 @@ 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
|
||||
|
||||
@@ -172,3 +172,69 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -33,13 +34,32 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
|
||||
}
|
||||
|
||||
func DefaultUserConfigPath() (string, error) {
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
userConfigDir, err := 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 {
|
||||
|
||||
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
configRoot, err := UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
@@ -130,3 +130,21 @@ 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)
|
||||
}
|
||||
}
|
||||
+102
-8
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -14,6 +15,31 @@ 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.
|
||||
@@ -104,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
// 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{
|
||||
@@ -118,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
@@ -195,7 +226,11 @@ 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 {
|
||||
@@ -217,8 +252,17 @@ 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 != "" {
|
||||
dst.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.
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
@@ -239,6 +283,7 @@ 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.
|
||||
@@ -367,6 +412,18 @@ 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 == "" {
|
||||
@@ -615,6 +672,7 @@ 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)
|
||||
}
|
||||
@@ -650,6 +708,24 @@ 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
|
||||
@@ -728,11 +804,16 @@ func unionStrings(base []string, extra []string) []string {
|
||||
}
|
||||
|
||||
type normalizeOptions struct {
|
||||
defaultOpenAIModel bool
|
||||
// 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
|
||||
}
|
||||
|
||||
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
|
||||
}
|
||||
|
||||
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
@@ -747,7 +828,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
if activeName != "" {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
return []ProviderProfile{}, ProviderProfile{}, nil
|
||||
}
|
||||
@@ -779,10 +860,17 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
// 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)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
|
||||
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 normalized, active, nil
|
||||
@@ -829,7 +917,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindOpenAI:
|
||||
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = OpenAIBaseURL
|
||||
if options.defaultOpenAIModel && profile.Model == "" {
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = modelregistry.DefaultModelID
|
||||
}
|
||||
return profile, nil
|
||||
@@ -846,6 +934,9 @@ 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)
|
||||
@@ -860,6 +951,9 @@ 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -898,6 +899,9 @@ 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)
|
||||
}
|
||||
@@ -912,6 +916,25 @@ 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",
|
||||
@@ -1055,6 +1078,130 @@ 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",
|
||||
@@ -1269,6 +1416,45 @@ 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{}})
|
||||
@@ -1551,3 +1737,97 @@ 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())
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,10 @@ 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).
|
||||
@@ -108,6 +112,25 @@ 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.
|
||||
@@ -202,6 +225,7 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -216,6 +240,7 @@ 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{
|
||||
@@ -228,6 +253,7 @@ 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
|
||||
@@ -252,6 +278,7 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -266,6 +293,7 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -312,6 +340,7 @@ 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"`
|
||||
@@ -338,6 +367,7 @@ 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{}
|
||||
|
||||
@@ -66,10 +66,13 @@ 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": "anthropic"}
|
||||
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
|
||||
]
|
||||
}`)
|
||||
|
||||
@@ -80,6 +83,30 @@ 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) {
|
||||
|
||||
@@ -154,6 +154,28 @@ 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))
|
||||
|
||||
@@ -272,6 +272,39 @@ 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() {
|
||||
|
||||
+166
-21
@@ -1,16 +1,17 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -211,6 +212,85 @@ 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()
|
||||
@@ -340,6 +420,53 @@ 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()
|
||||
@@ -393,28 +520,43 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// 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 outgoing bytes.Buffer
|
||||
client := &Client{
|
||||
reader: newMessageReader(&incoming),
|
||||
writer: newMessageWriter(&outgoing),
|
||||
reader: newMessageReader(clientReader),
|
||||
writer: newMessageWriter(clientWriter),
|
||||
nextID: 1,
|
||||
}
|
||||
var result struct {
|
||||
@@ -426,6 +568,9 @@ 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 {
|
||||
|
||||
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
||||
}
|
||||
return baseURL.ResolveReference(endpointURL).String(), nil
|
||||
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
|
||||
}
|
||||
|
||||
func rpcResponseKey(id any) string {
|
||||
@@ -873,10 +877,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 get the default client unchanged.
|
||||
// not declare OAuth use the default transport with the same MCP redirect guard.
|
||||
func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
||||
return http.DefaultClient, nil
|
||||
return mcpHTTPClient(server, nil), nil
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{})
|
||||
if err != nil {
|
||||
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
httpClient: http.DefaultClient,
|
||||
now: time.Now,
|
||||
}
|
||||
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
|
||||
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -149,6 +150,141 @@ 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.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
}
|
||||
@@ -370,7 +370,14 @@ func TestFullFlowStoresTokens(t *testing.T) {
|
||||
redirect := parsed.Query().Get("redirect_uri")
|
||||
go func() {
|
||||
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
|
||||
_, _ = http.Get(cbURL)
|
||||
for i := 0; i < 20; i++ {
|
||||
resp, err := http.Get(cbURL)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package modelregistry
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
@@ -42,8 +43,20 @@ type ListOptions struct {
|
||||
Capability ModelCapability
|
||||
}
|
||||
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
// defaultRegistry builds the default registry exactly once and caches the result.
|
||||
// The default catalog is static, every accessor returns clones, and a built
|
||||
// Registry is never mutated (concurrent reads of its maps are safe), so a single
|
||||
// shared instance is correct — and it avoids re-cloning all entries and
|
||||
// recompiling every fuzzy-match regex on the per-frame TUI paths (title chip,
|
||||
// context gauge, pickers) that call DefaultRegistry each render.
|
||||
var defaultRegistry = sync.OnceValues(func() (Registry, error) {
|
||||
return NewRegistry(DefaultModelEntries())
|
||||
})
|
||||
|
||||
// DefaultRegistry returns the shared, immutable default model registry, building
|
||||
// it on first use.
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
return defaultRegistry()
|
||||
}
|
||||
|
||||
func DefaultModelEntries() []ModelEntry {
|
||||
@@ -76,11 +89,13 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
patterns []string
|
||||
deprecation *DeprecationRule
|
||||
upgradeTarget string
|
||||
fastVariant string
|
||||
}{
|
||||
"claude-sonnet-4.5": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
|
||||
upgradeTarget: "claude-opus-4.1",
|
||||
fastVariant: "claude-haiku-4.5",
|
||||
},
|
||||
"claude-opus-4.1": {
|
||||
defaultEffort: ReasoningEffortHigh,
|
||||
@@ -94,6 +109,7 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
"gemini-2.5-pro": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
|
||||
fastVariant: "gemini-2.5-flash",
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
defaultEffort: ReasoningEffortLow,
|
||||
@@ -102,6 +118,12 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
"gemini-2.5-flash-lite": {
|
||||
upgradeTarget: "gemini-2.5-flash",
|
||||
},
|
||||
"gpt-4.1": {
|
||||
fastVariant: "gpt-4.1-mini",
|
||||
},
|
||||
"gpt-4o": {
|
||||
fastVariant: "gpt-4o-mini",
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
upgradeTarget: "gpt-4.1",
|
||||
},
|
||||
@@ -143,6 +165,9 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
if extra.upgradeTarget != "" {
|
||||
entries[index].UpgradeTargetID = extra.upgradeTarget
|
||||
}
|
||||
if extra.fastVariant != "" {
|
||||
entries[index].FastVariantID = extra.fastVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,14 @@ type ModelEntry struct {
|
||||
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
|
||||
// models). Resolved and availability-checked by Registry.UpgradeTarget.
|
||||
UpgradeTargetID string
|
||||
Description string
|
||||
// FastVariantID, when set, names a genuinely faster same-family model this one
|
||||
// can drop down to (e.g. claude-sonnet-4.5 -> claude-haiku-4.5). Empty means no
|
||||
// fast variant. It is the single source of truth for the /fast command and the
|
||||
// "f" fast-mode marker: Registry.FastVariant walks it forward and BaseVariant
|
||||
// walks it in reverse, so a model is "in fast mode" iff another model names it
|
||||
// here. Resolved and availability-checked like UpgradeTargetID.
|
||||
FastVariantID string
|
||||
Description string
|
||||
}
|
||||
|
||||
// DeprecationRule describes how a deprecated model is phased out and what to use
|
||||
@@ -368,6 +375,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
|
||||
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
|
||||
}
|
||||
}
|
||||
// A non-empty FastVariantID must resolve to a known model, mirroring the
|
||||
// UpgradeTargetID check — otherwise /fast and the "f" fast-mode marker would
|
||||
// silently vanish at runtime on a catalog typo instead of failing loudly here.
|
||||
for _, entry := range registry.models {
|
||||
fastID := strings.TrimSpace(entry.FastVariantID)
|
||||
if fastID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := registry.Get(fastID); !ok {
|
||||
return Registry{}, fmt.Errorf("model %q fast variant %q does not resolve to a known model", entry.ID, fastID)
|
||||
}
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package modelregistry
|
||||
|
||||
import "strings"
|
||||
|
||||
// The base<->fast model mapping is data, not logic: it lives entirely in each
|
||||
// ModelEntry's FastVariantID field (populated in the catalog, validated in
|
||||
// NewRegistry). Adding a pair is a one-line catalog change — never a code change
|
||||
// here. Both directions derive from that one field: FastVariant walks a model to
|
||||
// the fast id it names, BaseVariant scans for the model that names id as its fast
|
||||
// variant. This is why "fast mode" is encoded by model identity and needs no flag.
|
||||
//
|
||||
// Only models with an unambiguous 1:1 fast counterpart carry a FastVariantID. A
|
||||
// model with no clean counterpart (e.g. a top-tier Opus, where both Sonnet and
|
||||
// Haiku would be candidates) is intentionally left unset, so /fast reports "no
|
||||
// fast mode available" for it rather than guessing.
|
||||
|
||||
// FastVariant resolves id to its configured faster same-family counterpart. It
|
||||
// resolves id to a concrete entry, reads that entry's FastVariantID, and returns
|
||||
// the target only when it resolves and is not deprecated. Returns (_, false) when
|
||||
// id is unknown, has no fast variant, or the variant is unavailable/deprecated.
|
||||
// Mirrors Registry.UpgradeTarget.
|
||||
func (registry Registry) FastVariant(id string) (ModelEntry, bool) {
|
||||
source, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
fastID := strings.TrimSpace(source.FastVariantID)
|
||||
if fastID == "" {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
target, ok := registry.Resolve(fastID)
|
||||
if !ok || target.Status == ModelStatusDeprecated {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
// BaseVariant resolves id to the base model that declares id as its fast variant
|
||||
// — the reverse of FastVariant. A non-empty result therefore means id IS a fast
|
||||
// model: this is exactly how the /fast command derives "currently in fast mode"
|
||||
// without storing any flag. Same resolution and availability rules as FastVariant.
|
||||
func (registry Registry) BaseVariant(id string) (ModelEntry, bool) {
|
||||
source, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
for _, entry := range registry.models {
|
||||
fastID := strings.TrimSpace(entry.FastVariantID)
|
||||
if fastID == "" {
|
||||
continue
|
||||
}
|
||||
target, ok := registry.Resolve(fastID)
|
||||
if !ok || target.ID != source.ID {
|
||||
continue
|
||||
}
|
||||
if entry.Status == ModelStatusDeprecated {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
base, ok := registry.Resolve(entry.ID)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return base, true
|
||||
}
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
|
||||
// HasFastVariant reports whether id has an available fast variant — i.e. whether
|
||||
// `/fast on` would switch to a faster model from here. It is the predicate the
|
||||
// TUI uses to show the "f" fast-mode marker on a model (in the model/provider
|
||||
// pickers and the active-model indicator).
|
||||
func (registry Registry) HasFastVariant(id string) bool {
|
||||
_, ok := registry.FastVariant(id)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package modelregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func testRegistry(t *testing.T) Registry {
|
||||
t.Helper()
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry() error = %v", err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// expectedFastPairs is the known-good base->fast mapping. It is a literal
|
||||
// regression guard: if the catalog silently drops or mis-wires a FastVariantID,
|
||||
// the forward/reverse assertions below fail loudly rather than making /fast a
|
||||
// quiet no-op. Keep in sync with the FastVariantID fields in catalog.go.
|
||||
var expectedFastPairs = map[string]string{
|
||||
"claude-sonnet-4.5": "claude-haiku-4.5",
|
||||
"gpt-4.1": "gpt-4.1-mini",
|
||||
"gpt-4o": "gpt-4o-mini",
|
||||
"gemini-2.5-pro": "gemini-2.5-flash",
|
||||
}
|
||||
|
||||
// Every FastVariantID declared in the catalog must resolve to a real, non-self,
|
||||
// non-deprecated model. NewRegistry already rejects an unresolvable id at build
|
||||
// time; this additionally guards the runtime availability contract that /fast and
|
||||
// the "f" marker depend on.
|
||||
func TestCatalogFastVariantsResolve(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
|
||||
if entry.FastVariantID == "" {
|
||||
continue
|
||||
}
|
||||
target, ok := registry.Resolve(entry.FastVariantID)
|
||||
if !ok {
|
||||
t.Errorf("model %q fast variant %q does not resolve", entry.ID, entry.FastVariantID)
|
||||
continue
|
||||
}
|
||||
if target.ID == entry.ID {
|
||||
t.Errorf("model %q names itself as its fast variant", entry.ID)
|
||||
}
|
||||
if target.Status == ModelStatusDeprecated {
|
||||
t.Errorf("model %q fast variant %q is deprecated", entry.ID, entry.FastVariantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No model may be both a base and a fast, or the two directions become ambiguous
|
||||
// (a model would be "currently fast" AND advertise a fast variant). Derived from
|
||||
// the registry so a bad catalog edit — e.g. chaining mini->nano — is caught.
|
||||
func TestFastVariantPairsUnambiguous(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
role := map[string]string{} // canonical id -> "base" | "fast"
|
||||
assign := func(id, r string) {
|
||||
entry, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if prev, seen := role[entry.ID]; seen && prev != r {
|
||||
t.Fatalf("model %q is declared as both a base and a fast variant", entry.ID)
|
||||
}
|
||||
role[entry.ID] = r
|
||||
}
|
||||
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
|
||||
if entry.FastVariantID == "" {
|
||||
continue
|
||||
}
|
||||
assign(entry.ID, "base")
|
||||
assign(entry.FastVariantID, "fast")
|
||||
}
|
||||
}
|
||||
|
||||
// The known pairs must be present and wired in both directions, and neither end
|
||||
// may leak into the other role (a base is not itself fast; a fast has no fast).
|
||||
func TestFastVariantAndBaseVariant(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
|
||||
for base, fast := range expectedFastPairs {
|
||||
// base -> fast (forward), and the fast id has NO fast variant of its own.
|
||||
if got, ok := registry.FastVariant(base); !ok {
|
||||
t.Errorf("FastVariant(%q) = not found, want the fast variant", base)
|
||||
} else if canon, _ := registry.Resolve(fast); got.ID != canon.ID {
|
||||
t.Errorf("FastVariant(%q).ID = %q, want %q", base, got.ID, canon.ID)
|
||||
}
|
||||
if _, ok := registry.FastVariant(fast); ok {
|
||||
t.Errorf("FastVariant(%q) resolved, but a fast model should have no fast variant", fast)
|
||||
}
|
||||
|
||||
// fast -> base (reverse), and the base id is NOT itself a fast variant.
|
||||
if got, ok := registry.BaseVariant(fast); !ok {
|
||||
t.Errorf("BaseVariant(%q) = not found, want the base variant", fast)
|
||||
} else if canon, _ := registry.Resolve(base); got.ID != canon.ID {
|
||||
t.Errorf("BaseVariant(%q).ID = %q, want %q", fast, got.ID, canon.ID)
|
||||
}
|
||||
if _, ok := registry.BaseVariant(base); ok {
|
||||
t.Errorf("BaseVariant(%q) resolved, but a base model is not a fast variant", base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasFastVariant is the TUI marker predicate: true only for base models with an
|
||||
// available fast variant, false for the fast models themselves, unpaired models,
|
||||
// and unknown ids.
|
||||
func TestHasFastVariant(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
for base, fast := range expectedFastPairs {
|
||||
if !registry.HasFastVariant(base) {
|
||||
t.Errorf("HasFastVariant(%q) = false, want true", base)
|
||||
}
|
||||
if registry.HasFastVariant(fast) {
|
||||
t.Errorf("HasFastVariant(%q) = true, want false (a fast model has no fast variant)", fast)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"claude-opus-4.1", "definitely-not-a-real-model", ""} {
|
||||
if registry.HasFastVariant(id) {
|
||||
t.Errorf("HasFastVariant(%q) = true, want false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastVariantAbsentAndUnknown(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
|
||||
// A real model with no configured fast/base variant reports none in both
|
||||
// directions (a valid, handled case — /fast will say "no fast mode available").
|
||||
const noVariant = "claude-opus-4.1"
|
||||
if _, ok := registry.Resolve(noVariant); !ok {
|
||||
t.Skipf("%q not in registry; skipping no-variant case", noVariant)
|
||||
}
|
||||
if _, ok := registry.FastVariant(noVariant); ok {
|
||||
t.Errorf("FastVariant(%q) resolved, want none", noVariant)
|
||||
}
|
||||
if _, ok := registry.BaseVariant(noVariant); ok {
|
||||
t.Errorf("BaseVariant(%q) resolved, want none", noVariant)
|
||||
}
|
||||
|
||||
// An unknown id resolves to nothing in either direction.
|
||||
if _, ok := registry.FastVariant("definitely-not-a-real-model"); ok {
|
||||
t.Error("FastVariant(unknown) resolved, want none")
|
||||
}
|
||||
if _, ok := registry.BaseVariant("definitely-not-a-real-model"); ok {
|
||||
t.Error("BaseVariant(unknown) resolved, want none")
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
// 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) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
|
||||
@@ -44,10 +44,17 @@ type Manager struct {
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
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
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
@@ -83,9 +90,13 @@ 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: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,30 @@ 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,6 +1,33 @@
|
||||
package oauth
|
||||
|
||||
import "strings"
|
||||
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
|
||||
}
|
||||
|
||||
// providerPreset is a baked-in default OAuth configuration for a well-known
|
||||
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
|
||||
|
||||
@@ -46,6 +46,46 @@ 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{
|
||||
|
||||
@@ -41,6 +41,26 @@ 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"`
|
||||
|
||||
@@ -123,8 +123,10 @@ 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),
|
||||
@@ -133,7 +135,11 @@ 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://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_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("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
|
||||
|
||||
@@ -23,8 +23,10 @@ var expectedCatalogIDs = []string{
|
||||
"together",
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
"minimaxi-cn",
|
||||
"mistral",
|
||||
"github",
|
||||
"bedrock",
|
||||
@@ -34,6 +36,10 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"zai-cn",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
@@ -96,6 +102,28 @@ 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 == "" {
|
||||
@@ -263,8 +291,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportGoogle: {"google"},
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
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"},
|
||||
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"},
|
||||
}
|
||||
|
||||
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 !endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
||||
}
|
||||
|
||||
// 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"} {
|
||||
// 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"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
||||
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
|
||||
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
|
||||
}
|
||||
if endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = true, want false", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
|
||||
request, allowLoopbackOrPrivate, 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), allowLoopback)
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
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)
|
||||
allowLoopback := endpointIsLoopback(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
|
||||
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); 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, allowLoopback, nil
|
||||
return request, allowLoopbackOrPrivate, 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.
|
||||
// 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 {
|
||||
// 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 {
|
||||
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 allowLoopback {
|
||||
if allowLoopbackOrPrivate {
|
||||
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 != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,17 +407,19 @@ 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 != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -446,7 +448,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, allowLoopback bool) *http.Client {
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
|
||||
if resolver == nil {
|
||||
resolver = defaultResolver{}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
} else {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopback)
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
}
|
||||
}
|
||||
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
||||
// allowLoopback covers only the user's own configured base_url, not a 3xx
|
||||
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
// allowLoopbackOrPrivate 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)
|
||||
},
|
||||
}
|
||||
@@ -519,7 +521,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, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate 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)
|
||||
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@ 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"},
|
||||
@@ -88,10 +104,8 @@ 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": {
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
},
|
||||
"minimax": minimaxCuratedModels,
|
||||
"minimaxi-cn": minimaxCuratedModels,
|
||||
"mistral": {
|
||||
{ID: "mistral-large-latest", Description: "catalog default"},
|
||||
{ID: "codestral-latest", Description: "coding model"},
|
||||
@@ -123,12 +137,8 @@ var curatedModels = map[string][]Model{
|
||||
"bankr": {
|
||||
{ID: "bankr-large", Description: "catalog default"},
|
||||
},
|
||||
"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"},
|
||||
},
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
// 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,6 +37,16 @@ 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 {
|
||||
@@ -88,3 +98,66 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ 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,6 +118,8 @@ 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,6 +85,104 @@ 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,6 +2,7 @@ package providermodeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -267,3 +268,61 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ 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.
|
||||
@@ -251,14 +258,22 @@ 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
|
||||
// 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.
|
||||
// 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.
|
||||
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 := codexAccountFromStore(profile.Name)
|
||||
account := codexAccountForKey(accountKey)
|
||||
if account == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -288,17 +303,20 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 ""
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
token, ok, err := store.Load(oauth.ProviderKey(providerName))
|
||||
token, ok, err := store.Load(key)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
|
||||
// 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{
|
||||
@@ -322,11 +323,10 @@ 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. 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()
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
@@ -344,8 +344,9 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
|
||||
CatalogID: "chatgpt",
|
||||
Model: "gpt-5",
|
||||
}, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-factory-test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-factory-test",
|
||||
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
@@ -407,10 +408,61 @@ func (transport *captureTransport) body() io.Reader {
|
||||
return strings.NewReader(transport.requestBody)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
|
||||
declarations = append(declarations, geminiFunctionDeclaration{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.Parameters,
|
||||
Parameters: sanitizeGeminiSchema(tool.Parameters),
|
||||
})
|
||||
}
|
||||
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
|
||||
@@ -350,6 +350,71 @@ 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{}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -504,3 +505,129 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -25,9 +27,9 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
|
||||
|
||||
// ErrStreamStalled reports that a streaming upstream kept the connection alive
|
||||
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
|
||||
// actual output for streamContentStallFactor × the idle timeout. Without this an
|
||||
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
|
||||
// reasoning models — would hang the agent indefinitely.
|
||||
// actual output for ContentStallTimeout(idle). Without this an upstream that
|
||||
// heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama reasoning
|
||||
// models — would hang the agent indefinitely.
|
||||
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
|
||||
|
||||
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
|
||||
@@ -38,7 +40,7 @@ var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection
|
||||
// stuck or very slow.
|
||||
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
|
||||
if errors.Is(err, ErrStreamStalled) {
|
||||
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
|
||||
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", ContentStallTimeout(idleTimeout))
|
||||
}
|
||||
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
|
||||
}
|
||||
@@ -53,13 +55,32 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
|
||||
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
|
||||
const DefaultStreamIdleTimeout = 5 * time.Minute
|
||||
|
||||
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
|
||||
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
|
||||
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
|
||||
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
|
||||
// producing request — one that emits data between heartbeats — is never killed; the
|
||||
// content watchdog only catches a stream that heartbeats while producing nothing.
|
||||
const streamContentStallFactor = 2
|
||||
// ContentStallTimeout bounds a heartbeat-but-no-output stream: keep-alives reset
|
||||
// the idle watchdog (a heartbeating upstream is not "dead"), but if NO real data
|
||||
// line arrives for this long the stream is aborted (ErrStreamStalled). Scaled
|
||||
// above idleTimeout (so a slow-but-producing request that emits data between
|
||||
// heartbeats is never killed — the watchdog only ever fires when NOTHING real
|
||||
// arrives), but only 1.2× rather than the old 2×: a genuine heartbeat-pause
|
||||
// stall on chatgpt/gpt-5.x rarely recovers, so a ~10-minute dead wait (at the 5m
|
||||
// idle default) was a terrible UX for a doomed turn. At the default idle this is
|
||||
// 6 minutes; it still scales with ZERO_STREAM_IDLE_TIMEOUT. A returned value <= 0
|
||||
// (idle watchdog disabled) leaves the content watchdog off too.
|
||||
func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
|
||||
if idleTimeout <= 0 {
|
||||
return 0
|
||||
}
|
||||
// 1.2× computed as idle + idle/5 (not idle*6/5), plus a clamp: a
|
||||
// pathologically large ZERO_STREAM_IDLE_TIMEOUT could make idle*6 overflow
|
||||
// int64 and wrap to a NEGATIVE duration, which would arm the content timer
|
||||
// to fire immediately and abort every stream. Clamping to the max duration
|
||||
// on overflow just means "effectively no content watchdog" — the sane
|
||||
// result for an absurd idle timeout.
|
||||
extra := idleTimeout / 5
|
||||
if idleTimeout > math.MaxInt64-extra {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return idleTimeout + extra
|
||||
}
|
||||
|
||||
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
|
||||
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
|
||||
@@ -122,6 +143,21 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
|
||||
// unaffected (the stream-idle + content watchdogs cover that).
|
||||
// - IdleConnTimeout is shortened so a connection that went idle across a pause
|
||||
// is closed (and re-dialed fresh) rather than reused after it has gone stale.
|
||||
// - DisableKeepAlives on darwin only: ResponseHeaderTimeout/IdleConnTimeout
|
||||
// above catch a reused connection that's fully dead (never responds) or
|
||||
// idle past the timeout, but not one that's alive-but-severely-degraded —
|
||||
// e.g. reused shortly after a prior request on the same host (well within
|
||||
// 30s, common across quick retries of the same turn), where it still
|
||||
// delivers real bytes, just at a crippled rate, resetting Zero's stream
|
||||
// idle/content-stall watchdogs (which only fire on true silence) without
|
||||
// ever recovering. That degraded-not-dead case is indistinguishable from
|
||||
// genuine backend slowness from inside the stream, so the only reliable
|
||||
// fix is removing pooling from the equation entirely on the one platform
|
||||
// where this class of bug has actually reproduced. A fresh TCP+TLS
|
||||
// handshake per request costs low tens of milliseconds — negligible next
|
||||
// to the minutes-long stalls this avoids — and this doesn't touch
|
||||
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
|
||||
// pooled connections around as long.
|
||||
var sharedHTTPClient = func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
|
||||
@@ -132,6 +168,7 @@ var sharedHTTPClient = func() *http.Client {
|
||||
// content-stall watchdogs.
|
||||
transport.ResponseHeaderTimeout = 120 * time.Second
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
transport.DisableKeepAlives = runtime.GOOS == "darwin"
|
||||
return &http.Client{Transport: transport}
|
||||
}()
|
||||
|
||||
@@ -285,7 +322,7 @@ func ScanSSEDataWithContext(
|
||||
// Content watchdog: only real data lines reset it (keep-alives do not), so a
|
||||
// stream that heartbeats without producing output is bounded instead of
|
||||
// hanging forever.
|
||||
contentTimeout := idleTimeout * streamContentStallFactor
|
||||
contentTimeout := ContentStallTimeout(idleTimeout)
|
||||
content := time.NewTimer(contentTimeout)
|
||||
defer content.Stop()
|
||||
contentC = content.C
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -203,11 +205,36 @@ func TestUpstreamUnreachable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ContentStallTimeout is 1.2× the idle timeout, disabled (0) when idle is
|
||||
// disabled, and must never overflow to a negative duration for an absurdly
|
||||
// large idle timeout (which would arm the content timer to fire immediately
|
||||
// and abort every stream).
|
||||
func TestContentStallTimeout(t *testing.T) {
|
||||
cases := []struct {
|
||||
idle time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{5 * time.Minute, 6 * time.Minute},
|
||||
{30 * time.Second, 36 * time.Second},
|
||||
{100 * time.Millisecond, 120 * time.Millisecond},
|
||||
{0, 0},
|
||||
{-1, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ContentStallTimeout(c.idle); got != c.want {
|
||||
t.Fatalf("ContentStallTimeout(%v) = %v, want %v", c.idle, got, c.want)
|
||||
}
|
||||
}
|
||||
// Overflow guard: idle*6 would wrap negative; the clamp keeps it positive.
|
||||
if got := ContentStallTimeout(math.MaxInt64); got <= 0 {
|
||||
t.Fatalf("ContentStallTimeout(MaxInt64) = %v, must stay positive (no overflow wrap)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
|
||||
// feed the idle watchdog (so it never fires), but the content watchdog
|
||||
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
|
||||
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
|
||||
// hang.
|
||||
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
|
||||
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
|
||||
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
defer func() { _ = pw.Close() }()
|
||||
@@ -235,7 +262,8 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
|
||||
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
|
||||
// Keep-alives reset idle but not content.
|
||||
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
|
||||
}()
|
||||
|
||||
@@ -258,7 +286,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
// One data line every 30ms for ~300ms (well past the 200ms content window,
|
||||
// One data line every 30ms for ~300ms (well past the 120ms content window,
|
||||
// with comfortable slack under the 100ms idle timeout), so the content
|
||||
// watchdog keeps resetting, then close cleanly.
|
||||
for i := 0; i < 10; i++ {
|
||||
@@ -290,16 +318,16 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
}
|
||||
|
||||
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
|
||||
// it reports the content window (idle × factor) and must NOT claim the upstream
|
||||
// "stopped sending data" (keep-alives were still arriving).
|
||||
// it reports the content window (ContentStallTimeout) and must NOT claim the
|
||||
// upstream "stopped sending data" (keep-alives were still arriving).
|
||||
func TestStreamTimeoutMessage(t *testing.T) {
|
||||
idle := 5 * time.Minute
|
||||
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
|
||||
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
|
||||
}
|
||||
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
|
||||
if !strings.Contains(stalled, "no output for 10m") {
|
||||
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
|
||||
if !strings.Contains(stalled, "no output for 6m") {
|
||||
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
|
||||
}
|
||||
if strings.Contains(stalled, "stopped sending data") {
|
||||
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
|
||||
@@ -324,6 +352,17 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
if tr.IdleConnTimeout != 30*time.Second {
|
||||
t.Fatalf("IdleConnTimeout = %v, want 30s", tr.IdleConnTimeout)
|
||||
}
|
||||
// DisableKeepAlives is the stronger mitigation scoped to darwin only: the
|
||||
// two timeouts above catch a reused connection that's fully dead or idle
|
||||
// too long, but not one that's alive-and-degraded (still delivers real
|
||||
// bytes, just at a crippled rate) — which resets Zero's stream watchdogs
|
||||
// without ever recovering. Removing pooling from the equation entirely is
|
||||
// only worth its reconnect cost on the platform this class of bug has
|
||||
// actually reproduced on.
|
||||
wantDisableKeepAlives := runtime.GOOS == "darwin"
|
||||
if tr.DisableKeepAlives != wantDisableKeepAlives {
|
||||
t.Fatalf("DisableKeepAlives = %v, want %v (GOOS=%s)", tr.DisableKeepAlives, wantDisableKeepAlives, runtime.GOOS)
|
||||
}
|
||||
if HTTPClient(nil) != got {
|
||||
t.Fatal("HTTPClient(nil) must return a shared instance so the conn pool is reused")
|
||||
}
|
||||
|
||||
@@ -9,22 +9,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
|
||||
// sandbox network policy gates sandboxed shell egress, not first-party
|
||||
// in-process web tools.
|
||||
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(t *testing.T) {
|
||||
// TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy verifies
|
||||
// that first-party in-process network tools are governed by their permission
|
||||
// metadata, not by the sandboxed shell egress policy.
|
||||
func TestEvaluatePromptsForNetworkToolsButExemptsThemFromShellNetworkPolicy(t *testing.T) {
|
||||
base := Policy{Mode: ModeEnforce, Network: NetworkDeny}
|
||||
|
||||
engine := NewEngine(EngineOptions{Policy: base})
|
||||
d := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
|
||||
})
|
||||
if d.Action != ActionAllow || d.Block != nil {
|
||||
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
|
||||
if d.Action != ActionPrompt || d.Block != nil {
|
||||
t.Fatalf("web_search must prompt before permission is granted, got %#v", d)
|
||||
}
|
||||
if d.Reason == ReasonNetworkBlocked {
|
||||
t.Fatalf("web_search prompt should come from tool permission, not shell network policy: %#v", d)
|
||||
}
|
||||
|
||||
allowed := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
})
|
||||
if allowed.Action != ActionAllow || allowed.Block != nil {
|
||||
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
|
||||
|
||||
@@ -19,6 +19,13 @@ var errWindowsSandboxNotInitialized = errors.New(
|
||||
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
|
||||
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
|
||||
|
||||
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
|
||||
// the unelevated tier: the restricted-token write-jail is enforced (the command
|
||||
// runner applies the workspace ACLs itself, which needs no Administrator
|
||||
// rights), but the WFP network filters are Administrator-only and absent.
|
||||
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
|
||||
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
|
||||
|
||||
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
|
||||
// marker exists. A missing marker is the common fresh-install state, so the
|
||||
// caller degrades rather than bricking the command. Indirected through a var so
|
||||
@@ -174,19 +181,28 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
|
||||
// applies WFP network filters + workspace ACLs and writes a marker). Without
|
||||
// it the command runner hard-fails — so on the default preference, DEGRADE to
|
||||
// the in-process policy gate + per-command approval (matching Linux/macOS when
|
||||
// their sandbox is unavailable) instead of bricking every command. A strict
|
||||
// caller (--sandbox require) still gets a clear error pointing at setup.
|
||||
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
|
||||
// (it applies WFP network filters + workspace ACLs and writes a marker).
|
||||
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
|
||||
// tier: the command runner applies the workspace ACL plan itself (DACL edits
|
||||
// on user-owned roots need no Administrator rights) and wraps the command in
|
||||
// the write-restricted token, so the filesystem write-jail holds. Only the
|
||||
// WFP network filters are Administrator-only, so network stays with the
|
||||
// in-process approval gate at that tier. A profile that needs the sandbox
|
||||
// solely for network isolation gains nothing from the unelevated tier and
|
||||
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
|
||||
// still gets a clear error pointing at setup.
|
||||
windowsNeedsSetup := false
|
||||
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
|
||||
if preference == SandboxPreferenceRequire {
|
||||
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
|
||||
}
|
||||
enforcementLevel = EnforcementDegraded
|
||||
windowsNeedsSetup = true
|
||||
if profile.FileSystem.Kind == FileSystemRestricted {
|
||||
enforcementLevel = EnforcementUnelevated
|
||||
} else {
|
||||
enforcementLevel = EnforcementDegraded
|
||||
}
|
||||
}
|
||||
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
|
||||
downgradeReason := ""
|
||||
@@ -196,7 +212,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
downgradeReason = windowsSetupDowngradeReason
|
||||
}
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
||||
if enforcementLevel == EnforcementUnelevated {
|
||||
downgradeReason = windowsUnelevatedDowngradeReason
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available &&
|
||||
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
|
||||
markers := backend.SandboxEnvMarkers(policy)
|
||||
if !wrapped && backend.Name != BackendWSL {
|
||||
markers = nil
|
||||
|
||||
@@ -72,7 +72,10 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,64 @@ exit 0
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsUnelevatedRealSandboxSmoke exercises the unelevated tier end to
|
||||
// end WITHOUT running the elevated setup helper: the command runner applies
|
||||
// the workspace ACLs itself, a write inside the workspace succeeds under the
|
||||
// restricted token, and a write outside every granted root is denied. Unlike
|
||||
// the elevated smoke above it needs no Administrator terminal.
|
||||
func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
// cmd.exe rather than powershell.exe: managed PowerShell cannot initialize
|
||||
// its crypto provider under a write-restricted token on some hosts
|
||||
// (0x8009001d, the same restricted-token limitation the runner documents for
|
||||
// Schannel), and the write-jail assertion only needs a native shell.
|
||||
insideMarker := filepath.Join(root, "unelevated-write-ok.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
|
||||
}, 0)
|
||||
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "ok" {
|
||||
t.Fatalf("unelevated sandboxed write marker = %q, %v; want ok", bytes, err)
|
||||
}
|
||||
if _, err := os.Stat(WindowsUnelevatedSetupMarkerPath(sandboxHome)); err != nil {
|
||||
t.Fatalf("expected the unelevated setup marker to be recorded: %v", err)
|
||||
}
|
||||
|
||||
outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", "echo leaked>" + outsideMarker,
|
||||
}, 1)
|
||||
if _, err := os.Stat(outsideMarker); err == nil {
|
||||
t.Fatalf("unelevated sandbox allowed a write outside every granted root")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat outside marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package sandbox
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -128,7 +129,10 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,16 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
// EnforcementUnelevated is the Windows-only middle tier used when the
|
||||
// elevated `zero sandbox setup` has not run: commands still wrap through the
|
||||
// command runner with a write-restricted token and workspace ACLs (which the
|
||||
// runner can apply without Administrator rights), but the WFP network
|
||||
// filters are absent, so network isolation stays with the in-process
|
||||
// approval gate.
|
||||
EnforcementUnelevated EnforcementLevel = "unelevated"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
|
||||
@@ -8,14 +8,21 @@ import (
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
|
||||
switch config.SandboxLevel {
|
||||
case WindowsSandboxLevelRestrictedToken:
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
case WindowsSandboxLevelUnelevated:
|
||||
if err := ensureWindowsUnelevatedSetup(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(stderr, "%s: unsupported Windows sandbox level %q\n", WindowsSandboxCommandRunnerName, config.SandboxLevel)
|
||||
return 1
|
||||
}
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if err := ValidateWindowsNetworkPolicy(config.PermissionProfile.Network); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
@@ -57,3 +64,32 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current
|
||||
// (non-elevated) process so the write-restricted token has somewhere its
|
||||
// capability SIDs are granted. DACL edits on user-owned workspace and temp
|
||||
// roots need no Administrator rights; the WFP network filters DO, so this tier
|
||||
// provisions no network enforcement — the offline-marker SID composed into the
|
||||
// token stays inert until an elevated `zero sandbox setup` installs the block
|
||||
// filters. Applied plans are recorded by hash so repeat commands skip the
|
||||
// re-apply; like the elevated setup, grants are left in place (the rollback is
|
||||
// deliberately discarded) because they only name synthetic capability SIDs
|
||||
// that no other token carries.
|
||||
func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
|
||||
}
|
||||
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// On Windows, a not-yet-set-up sandbox must DEGRADE (run unwrapped with a
|
||||
// downgrade reason) on the default preference — never brick the command — while
|
||||
// a strict --sandbox require still errors with a setup hint. Once the marker
|
||||
// exists, enforcement is native and the command wraps.
|
||||
func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
// On Windows, a not-yet-set-up sandbox must fall back to the UNELEVATED tier
|
||||
// (wrapped through the runner with the write-restricted token, network left to
|
||||
// the approval gate) on the default preference — never brick the command —
|
||||
// while a strict --sandbox require still errors with a setup hint. Once the
|
||||
// marker exists, enforcement is native and the command wraps.
|
||||
func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
|
||||
@@ -24,13 +26,70 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
ValidateExecution: true,
|
||||
}
|
||||
|
||||
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
|
||||
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
|
||||
// documenting the missing network enforcement, no error.
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
auto := base
|
||||
auto.Preference = SandboxPreferenceAuto
|
||||
req, err := mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("auto must degrade, not error: %v", err)
|
||||
t.Fatalf("auto must fall back, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementUnelevated {
|
||||
t.Fatalf("enforcement = %v, want unelevated", req.EnforcementLevel)
|
||||
}
|
||||
if !req.CommandWrapped {
|
||||
t.Fatal("unelevated command must still be wrapped through the sandbox runner")
|
||||
}
|
||||
if req.DowngradeReason == "" || !strings.Contains(req.DowngradeReason, "network") {
|
||||
t.Fatalf("downgrade reason = %q, want an explanation of the missing network enforcement", req.DowngradeReason)
|
||||
}
|
||||
|
||||
// Marker missing + require -> hard error pointing at setup.
|
||||
require := base
|
||||
require.Preference = SandboxPreferenceRequire
|
||||
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
|
||||
t.Fatalf("require must error with the setup hint, got %v", err)
|
||||
}
|
||||
|
||||
// Marker present -> native, wrapped, no downgrade reason.
|
||||
windowsSandboxInitialized = func() bool { return true }
|
||||
req, err = mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("initialized auto: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
|
||||
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
|
||||
}
|
||||
if req.DowngradeReason != "" {
|
||||
t.Fatalf("native downgrade reason = %q, want empty", req.DowngradeReason)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that needs the platform sandbox ONLY for network isolation gains
|
||||
// nothing from the unelevated tier (which cannot enforce network), so it must
|
||||
// keep the old degrade-to-policy-gate behavior.
|
||||
func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(t *testing.T) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
|
||||
mgr := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: "windows",
|
||||
Backend: Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: "zero.exe", Platform: "windows"},
|
||||
})
|
||||
req, err := mgr.BuildExecutionRequest(SandboxManagerRequest{
|
||||
WorkspaceRoot: `C:\ws`,
|
||||
Command: CommandSpec{Name: "bash", Args: []string{"-c", "echo hi"}},
|
||||
Policy: DefaultPolicy(),
|
||||
Profile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
Preference: SandboxPreferenceAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("network-only profile must degrade, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
|
||||
@@ -41,21 +100,4 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
if req.DowngradeReason == "" {
|
||||
t.Fatal("expected a downgrade reason explaining the missing setup")
|
||||
}
|
||||
|
||||
// Marker missing + require -> hard error pointing at setup.
|
||||
require := base
|
||||
require.Preference = SandboxPreferenceRequire
|
||||
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
|
||||
t.Fatalf("require must error with the setup hint, got %v", err)
|
||||
}
|
||||
|
||||
// Marker present -> native, wrapped.
|
||||
windowsSandboxInitialized = func() bool { return true }
|
||||
req, err = mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("initialized auto: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
|
||||
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,14 @@ type WindowsSandboxLevel string
|
||||
|
||||
const (
|
||||
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
// WindowsSandboxLevelUnelevated is the restricted-token tier without the
|
||||
// elevated setup: the runner applies the workspace ACL plan itself before
|
||||
// launching (no Administrator rights needed) and skips the elevated setup
|
||||
// marker check. Network is NOT enforced at this level — the WFP filters need
|
||||
// the elevated setup — so callers must keep network behind the approval gate.
|
||||
WindowsSandboxLevelUnelevated WindowsSandboxLevel = "unelevated"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
)
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
@@ -271,13 +277,20 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot)
|
||||
// The unelevated enforcement tier maps to the runner's unelevated level: same
|
||||
// restricted token, but the runner applies the workspace ACLs itself instead
|
||||
// of requiring the elevated setup marker.
|
||||
level := WindowsSandboxLevelRestrictedToken
|
||||
if execRequest.EnforcementLevel == EnforcementUnelevated {
|
||||
level = WindowsSandboxLevelUnelevated
|
||||
}
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -381,7 +394,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
|
||||
|
||||
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
|
||||
switch level {
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user