mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a766163401 | ||
|
|
16ce70b7a9 |
No files matched your search
@@ -54,10 +54,8 @@ jobs:
|
||||
# is published under the pushed git tag. If they disagree, install.sh/install.ps1
|
||||
# and the npm postinstall all build a download URL that 404s. Fail fast so a
|
||||
# forgotten version bump can never publish mis-versioned, un-installable assets.
|
||||
# Matches on the ref (not event_name) because release-please dispatches this
|
||||
# workflow at the tag ref — its GITHUB_TOKEN-created tag cannot fire `push`.
|
||||
- name: Check tag matches package.json version
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
run: |
|
||||
pkg_version="$(node -p "require('./package.json').version")"
|
||||
@@ -80,15 +78,13 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# On a `v*` tag ref (pushed manually, or dispatched at the tag by the
|
||||
# release-please workflow), publish the packaged archive + .sha256 as GitHub
|
||||
# Release assets so the documented `scripts/install.sh` / `install.ps1` path
|
||||
# (which reads /releases/latest) works. Uses the preinstalled `gh` (no new
|
||||
# action dependency); each matrix OS creates the release if absent
|
||||
# (best-effort — release-please normally creates it first) then uploads its
|
||||
# own assets with --clobber. Tags come from merging the release-please PR.
|
||||
# On a `v*` tag push, publish the packaged archive + .sha256 as GitHub Release
|
||||
# assets so the documented `scripts/install.sh` / `install.ps1` path (which reads
|
||||
# /releases/latest) works. Uses the preinstalled `gh` (no new action dependency);
|
||||
# each matrix OS creates the release if absent (best-effort) then uploads its own
|
||||
# assets with --clobber. NOTE: maintainers must push the tag — this never tags.
|
||||
- name: Publish to GitHub Release
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -96,89 +92,3 @@ jobs:
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Manual approval gate: this environment carries a required-reviewer rule
|
||||
# (Settings -> Environments -> npm-publish), so the job pauses until a
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
# No registry-url here on purpose: setup-node's registry-url writes an
|
||||
# .npmrc line referencing $NODE_AUTH_TOKEN, and npm hard-fails on the
|
||||
# unset env var. Trusted publishing needs no token at all.
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# OIDC trusted publishing needs npm >= 11.5.1. Use the npm bundled with
|
||||
# the pinned Node toolchain (Node 24 ships >= 11.5.1) rather than pulling
|
||||
# npm@latest from the registry inside the publish path — an unpinned
|
||||
# install there would be avoidable supply-chain exposure. Fail fast if a
|
||||
# future runner/toolchain change ever regresses the bundled npm.
|
||||
- name: Check npm supports trusted publishing
|
||||
run: |
|
||||
npm --version | node -e '
|
||||
const version = require("fs").readFileSync(0, "utf8").trim();
|
||||
const [major, minor, patch] = version.split(".").map(Number);
|
||||
const ok =
|
||||
major > 11 ||
|
||||
(major === 11 && (minor > 5 || (minor === 5 && patch >= 1)));
|
||||
if (!ok) {
|
||||
console.error(
|
||||
`::error::bundled npm ${version} is too old for OIDC trusted ` +
|
||||
`publishing (needs >= 11.5.1) — bump node-version in this job.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}"
|
||||
for asset in \
|
||||
"zero-v${version}-linux-x64.tar.gz" \
|
||||
"zero-v${version}-linux-arm64.tar.gz" \
|
||||
"zero-v${version}-macos-x64.tar.gz" \
|
||||
"zero-v${version}-macos-arm64.tar.gz" \
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write # push the release-PR branch, create the tag + GitHub Release
|
||||
pull-requests: write # open/update the release PR
|
||||
issues: write # create/apply release-tracking labels on the release PR
|
||||
actions: write # dispatch release-artifacts.yml once a release is cut
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release PR / tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 # v4.2.0
|
||||
|
||||
# Tags created with the default GITHUB_TOKEN never fire `on: push: tags`
|
||||
# workflows (GitHub suppresses workflows triggered by workflow-created
|
||||
# events), so release-artifacts.yml would not run for a release-please
|
||||
# tag. workflow_dispatch invoked through the API is exempt from that
|
||||
# suppression — dispatch the artifacts build at the new tag explicitly.
|
||||
- name: Build and publish release artifacts
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh workflow run release-artifacts.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "${{ steps.release.outputs.tag_name }}"
|
||||
@@ -1 +0,0 @@
|
||||
{".":"0.1.0"}
|
||||
+1
-21
@@ -6,32 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* publish zero to npm via release-please ([#367](https://github.com/Gitlawb/zero/issues/367)) ([8eccc26](https://github.com/Gitlawb/zero/commit/8eccc2669887bc38d35bc16a315c888e4d9ec43a))
|
||||
* **tui:** FILES sidebar panel with click-to-select and file drill-in ([#365](https://github.com/Gitlawb/zero/issues/365)) ([142c548](https://github.com/Gitlawb/zero/commit/142c548c89a8652ce300e64ddf1228ee36df7606))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** propagate credentials to every provider-build surface and pin children to the live provider ([#366](https://github.com/Gitlawb/zero/issues/366)) ([6e0a665](https://github.com/Gitlawb/zero/commit/6e0a665118fe0e09c4b07d482dd18f86045acd2b))
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- `--theme {auto|dark|light}` flag for the TUI (previously only the `ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
and `ZERO_NO_FADE`.
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
|
||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||
@@ -53,27 +51,6 @@ The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
Install with Bun, then fetch the binary in one of two ways:
|
||||
|
||||
```bash
|
||||
# Option A: run the installer manually
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
|
||||
# Option B: allow the postinstall to run on install
|
||||
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
|
||||
global install path, or prefer the install scripts below.
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
@@ -144,13 +121,6 @@ the key in the wizard:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
@@ -284,9 +254,9 @@ zero update check for newer releases
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_THEME=auto|dark|light` | selects the startup theme |
|
||||
| `--theme auto|dark|light` | selects the TUI theme from the CLI |
|
||||
| `/theme auto|dark|light` | switches theme inside the TUI |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
|
||||
Meaning does not rely on color alone; diffs, permissions, and statuses also use
|
||||
|
||||
-297
@@ -1,297 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
|
||||
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
|
||||
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
|
||||
|
||||
## 安装
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
|
||||
|
||||
```bash
|
||||
# 选项 A:手动运行安装程序
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
|
||||
# 选项 B:允许 postinstall 在安装时运行
|
||||
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
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) 发布。
|
||||
+1
-19
@@ -54,26 +54,8 @@ const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. To let it run on future installs, add zero to your\n' +
|
||||
'project\'s trustedDependencies:\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'then reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -24,38 +24,6 @@ Requirements:
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
To install with Bun, either run the installer manually after installing:
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Or allow the postinstall to run by adding the package to your project's
|
||||
`trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
|
||||
against the global install path, or use the install scripts below.
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
+21
-48
@@ -210,29 +210,27 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardedVisibleText flags forwarded final PROSE (OnText) specifically —
|
||||
// the only output whose re-stream on a stall retry would visibly DUPLICATE
|
||||
// answer text the user already read. Transient working indicators
|
||||
// (reasoning previews, tool-call "writing…" previews) are deliberately NOT
|
||||
// counted: a turn that streamed only those then stalled — the common
|
||||
// gpt-5.x / ollama "froze mid-write_file" case — is safe to re-issue, since
|
||||
// the TUI resets the tool-call preview on the next tool-call-start and a
|
||||
// stalled turn is never appended to `messages` (it returns on the error
|
||||
// before the append), so the retry re-sends clean context with no
|
||||
// conversation-state duplication.
|
||||
forwardedVisibleText := false
|
||||
// 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.
|
||||
forwardingOpts := zeroruntime.CollectOptions{OnUsage: options.OnUsage}
|
||||
if options.OnText != nil {
|
||||
forwardingOpts.OnText = func(s string) { forwardedVisibleText = true; options.OnText(s) }
|
||||
forwardingOpts.OnText = func(s string) { mark(); options.OnText(s) }
|
||||
}
|
||||
if options.OnReasoning != nil {
|
||||
forwardingOpts.OnReasoning = func(s string) { options.OnReasoning(s) }
|
||||
forwardingOpts.OnReasoning = func(s string) { mark(); options.OnReasoning(s) }
|
||||
}
|
||||
if options.OnToolCallStart != nil {
|
||||
forwardingOpts.OnToolCallStart = func(id, name string) { options.OnToolCallStart(id, name) }
|
||||
forwardingOpts.OnToolCallStart = func(id, name string) { mark(); options.OnToolCallStart(id, name) }
|
||||
}
|
||||
if options.OnToolCallDelta != nil {
|
||||
forwardingOpts.OnToolCallDelta = func(id, fragment string) { options.OnToolCallDelta(id, fragment) }
|
||||
forwardingOpts.OnToolCallDelta = func(id, fragment string) { mark(); 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)
|
||||
@@ -286,28 +284,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, ctx.Err()
|
||||
}
|
||||
// A stream idle/stall timeout is safely re-issued when the turn committed NO
|
||||
// answer text — no forwarded visible prose (forwardedVisibleText) and no
|
||||
// collected final text (collected.Text). This covers two cases:
|
||||
// 1. Nothing streamed at all before the connection died (the original
|
||||
// macOS stale-pooled-connection hang past the response-header timeout).
|
||||
// 2. The model streamed transient reasoning and began a tool call (e.g. a
|
||||
// large write_file) then froze mid-arguments — the common gpt-5.x /
|
||||
// ollama heartbeat-pause stall. That partial tool call is NEVER executed
|
||||
// (a turn with collected.Error returns before dispatch below) and NEVER
|
||||
// appended to `messages`, so a retry re-issues clean context; the only
|
||||
// re-render is transient previews, not duplicated answer text. This is
|
||||
// why the gate no longer excludes collected.ToolCalls: an incomplete
|
||||
// tool call from a timed-out stream is discard-and-retry, not output.
|
||||
// A turn that forwarded real prose is NOT retried (it would duplicate visible
|
||||
// answer text) and falls through to the error return below. Capped +
|
||||
// exponential backoff, with a user-visible notice per attempt.
|
||||
// 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.
|
||||
for attempt := 1; attempt <= maxStreamStallRetries &&
|
||||
isStreamTimeoutError(collected.Error) && !forwardedVisibleText &&
|
||||
collected.Text == ""; attempt++ {
|
||||
if notify := stallRetryNoticeFor(options); notify != nil {
|
||||
notify(attempt, maxStreamStallRetries)
|
||||
}
|
||||
isStreamTimeoutError(collected.Error) && !forwardedAnything &&
|
||||
collected.Text == "" && len(collected.ToolCalls) == 0; attempt++ {
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
@@ -2159,18 +2144,6 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}
|
||||
}
|
||||
|
||||
// A command that explicitly requests additional sandbox permissions
|
||||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||
// must consent to, even when the base command's sandbox decision was Allow.
|
||||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||
// dropped it, deadlocking the run. Keep this consistent with
|
||||
// shouldRequestPermission's own condition.
|
||||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
action = PermissionActionPrompt
|
||||
}
|
||||
|
||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
|
||||
@@ -37,35 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
|
||||
func (tool *recordingWebSearchTool) Name() string { return "web_search" }
|
||||
func (tool *recordingWebSearchTool) Description() string { return "test web search tool" }
|
||||
func (tool *recordingWebSearchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"query": {Type: "string"},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Safety() tools.Safety {
|
||||
return tools.Safety{
|
||||
SideEffect: tools.SideEffectNetwork,
|
||||
Permission: tools.PermissionPrompt,
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
}
|
||||
}
|
||||
func (tool *recordingWebSearchTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
tool.calls = append(tool.calls, cloneArgs(args))
|
||||
return tools.Result{Status: tools.StatusOK, Output: "1. T — https://x.test"}
|
||||
}
|
||||
|
||||
type sandboxDeniedRetryTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
@@ -894,7 +865,7 @@ func TestRunRejectsLocalWebFetchBeforePermissionRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||
func TestRunAdvertisesAllowedWebSearchInAutoMode(t *testing.T) {
|
||||
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreNetworkTools() {
|
||||
@@ -925,52 +896,6 @@ func TestRunAdvertisesPromptedWebSearchInAutoMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
search := &recordingWebSearchTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(search)
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "web_search"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"query":"private workspace detail"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
var requests []PermissionRequest
|
||||
|
||||
result, err := Run(context.Background(), "search", provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeAsk,
|
||||
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
requests = append(requests, request)
|
||||
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "network not approved"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer after denied tool call, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected one permission request, got %#v", requests)
|
||||
}
|
||||
if requests[0].ToolName != "web_search" || requests[0].Permission != string(tools.PermissionPrompt) || requests[0].SideEffect != string(tools.SideEffectNetwork) {
|
||||
t.Fatalf("unexpected permission request: %#v", requests[0])
|
||||
}
|
||||
if len(search.calls) != 0 {
|
||||
t.Fatalf("web_search backend must not run when permission is denied, got calls %#v", search.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
@@ -40,21 +40,6 @@ func reconnectNoticeFor(options Options) reconnectNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// stallRetryNoticeFor builds a notifier for the loop's content-stall retry (a
|
||||
// stream that connected and produced only transient output, then went silent).
|
||||
// Distinct wording from reconnectNoticeFor's "connection lost" because the
|
||||
// connection was fine — the model stalled. Surfaced through OnReasoning, the
|
||||
// non-content channel, so it never folds into the answer text. Nil when there
|
||||
// is no reasoning sink (the retry still happens silently).
|
||||
func stallRetryNoticeFor(options Options) reconnectNotifier {
|
||||
if options.OnReasoning == nil {
|
||||
return nil
|
||||
}
|
||||
return func(attempt, max int) {
|
||||
options.OnReasoning(fmt.Sprintf("\n[no output — model stalled; retrying %d/%d…]\n", attempt, max))
|
||||
}
|
||||
}
|
||||
|
||||
// streamWithReconnect issues request via provider.StreamCompletion and, on a
|
||||
// transient disconnect error, retries the connect up to maxStreamReconnects
|
||||
// times with exponential backoff. It returns the live stream on success, or the
|
||||
|
||||
@@ -10,25 +10,19 @@ import (
|
||||
)
|
||||
|
||||
// stallProvider connects successfully (HTTP 200) but the stream emits a stall/idle
|
||||
// timeout error for the first stallBefore calls, then succeeds with "done". The
|
||||
// pre-stall emission simulates different partial-output shapes:
|
||||
// - partialText: forwarded PROSE (StreamEventText) — must NOT be retried
|
||||
// (re-issuing would duplicate visible answer text).
|
||||
// - reasoningText: transient reasoning preview — IS retried.
|
||||
// - partialToolCall: a tool call started (+ arg delta) but never ended, the
|
||||
// "froze mid-write_file" case — IS retried (the incomplete call is never
|
||||
// executed or committed).
|
||||
// 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).
|
||||
type stallProvider struct {
|
||||
calls int32
|
||||
stallBefore int32
|
||||
partialText string
|
||||
reasoningText string
|
||||
partialToolCall string
|
||||
calls int32
|
||||
stallBefore int32
|
||||
partialText string
|
||||
reasoningText string
|
||||
}
|
||||
|
||||
func (p *stallProvider) StreamCompletion(_ context.Context, _ zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
n := atomic.AddInt32(&p.calls, 1)
|
||||
ch := make(chan zeroruntime.StreamEvent, 5)
|
||||
ch := make(chan zeroruntime.StreamEvent, 3)
|
||||
if n <= p.stallBefore {
|
||||
if p.partialText != "" {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.partialText}
|
||||
@@ -36,16 +30,9 @@ 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 6m (the model produced nothing)",
|
||||
Error: "provider stream error: no output for 10m (the model produced nothing)",
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
@@ -85,54 +72,21 @@ func TestRunDoesNotRetryStallAfterPartialOutput(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) {
|
||||
// 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) {
|
||||
p := &stallProvider{stallBefore: 1, reasoningText: "thinking hard"}
|
||||
result, err := Run(context.Background(), "go", p, Options{
|
||||
_, err := Run(context.Background(), "go", p, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
OnReasoning: func(string) {},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a stall after only reasoning should retry to success, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("a stall after forwarded reasoning must NOT be retried; want an error")
|
||||
}
|
||||
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)
|
||||
if got := atomic.LoadInt32(&p.calls); got != 1 {
|
||||
t.Fatalf("reasoning-then-stall must not retry, got %d calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,15 +455,11 @@ func TestHarnessTimeoutCancelsBlockedAgent(t *testing.T) {
|
||||
|
||||
// The timeout is longer than materialization of the small fixture even on
|
||||
// slower Windows CI runners, so the agent is reached before it blocks until
|
||||
// the deadline fires. 10s wasn't generous enough in practice — it failed
|
||||
// on an otherwise-unrelated PR merge (materialization alone consumed the
|
||||
// full budget on a contended Windows runner, so the agent was never
|
||||
// reached); widened for headroom, since the assertions below still bound
|
||||
// worst-case test time via that same deadline, not this constant.
|
||||
// the deadline fires.
|
||||
report := harness.Run(context.Background(), suitePath, suite, BenchmarkInput{
|
||||
TaskID: "document-stream-json-verify-events",
|
||||
WorkRoot: t.TempDir(),
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
|
||||
if !agentReached {
|
||||
|
||||
+2
-5
@@ -43,11 +43,8 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
NewProvider: deps.newProvider,
|
||||
RunAgent: agent.Run,
|
||||
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
|
||||
// exec surface, so ACP shell/file tools are confined — never run unconfined.
|
||||
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
|
||||
|
||||
+56
-136
@@ -22,7 +22,6 @@ 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"
|
||||
@@ -44,43 +43,37 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
type appDeps struct {
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
// exportActiveProvider pins spawned children to the run's provider (production:
|
||||
// config.SetActiveProviderEnv, set in defaultAppDeps — deliberately NOT filled
|
||||
// by fillAppDeps, so tests never mutate the process environment unless they
|
||||
// inject it). nil ⇒ no export.
|
||||
exportActiveProvider func(providerName string)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
getwd func() (string, error)
|
||||
stdin io.Reader
|
||||
userConfigPath func() (string, error)
|
||||
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
|
||||
newSessionStore func() *sessions.Store
|
||||
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
|
||||
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
|
||||
skillsDir func() string
|
||||
pluginsDir func() string
|
||||
toolsDir func() string
|
||||
newMCPStore func() (*mcp.PermissionStore, error)
|
||||
newMCPTokenStore func() (*mcp.TokenStore, error)
|
||||
newSandboxStore func() (*sandbox.GrantStore, error)
|
||||
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
|
||||
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
|
||||
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
|
||||
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
|
||||
detectVerifyPlan func(string) (verify.Plan, error)
|
||||
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
|
||||
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
|
||||
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
|
||||
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
|
||||
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type mcpToolRuntime interface {
|
||||
@@ -106,10 +99,9 @@ func Run(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
func defaultAppDeps() appDeps {
|
||||
return appDeps{
|
||||
getwd: os.Getwd,
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
getwd: os.Getwd,
|
||||
stdin: os.Stdin,
|
||||
userConfigPath: config.DefaultUserConfigPath,
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
options, err := config.DefaultResolveOptions(workspaceRoot)
|
||||
if err != nil {
|
||||
@@ -126,19 +118,13 @@ func defaultAppDeps() appDeps {
|
||||
return config.ResolveMCP(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
// Resolve the OAuth login ONCE: the bearer resolver and the login key it
|
||||
// bound must describe the same login (the key is passed on to the Codex
|
||||
// account-header resolver so it never re-selects independently).
|
||||
resolver, loginKey := oauthLoginForProfile(profile)
|
||||
return providers.New(profile, providers.Options{
|
||||
UserAgent: userAgent(),
|
||||
OAuthResolver: resolver,
|
||||
OAuthLoginKey: loginKey,
|
||||
OAuthResolver: oauthResolverForProfile(profile),
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
discoverProviderModels: defaultDiscoverProviderModels,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
detectLocalRuntimes: provideronboarding.DetectLocalRuntimes,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
return sessions.NewStore(sessions.StoreOptions{})
|
||||
},
|
||||
@@ -231,9 +217,9 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
// --theme <name> selects the TUI palette non-interactively (auto or any registered
|
||||
// theme; populates tui.Options.Theme, which resolveThemeMode prefers over
|
||||
// ZERO_THEME). Re-split --add-dir afterward so it may appear on either side of --theme.
|
||||
// --theme {auto|dark|light} selects the TUI palette non-interactively (populates
|
||||
// tui.Options.Theme, which resolveThemeMode prefers over ZERO_THEME). Re-split
|
||||
// --add-dir afterward so it may appear on either side of --theme.
|
||||
theme, args, err := splitLeadingThemeFlag(args)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
@@ -405,14 +391,6 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
|
||||
return 1
|
||||
}
|
||||
// First-run users reach for `zero login`/`zero logout` (reported in the
|
||||
// wild); point them at the real command instead of a bare usage pointer.
|
||||
switch strings.ToLower(args[0]) {
|
||||
case "login", "logout":
|
||||
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
|
||||
return 1
|
||||
}
|
||||
@@ -443,9 +421,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.probeProviderHealth == nil {
|
||||
deps.probeProviderHealth = defaults.probeProviderHealth
|
||||
}
|
||||
if deps.discoverProviderModels == nil {
|
||||
deps.discoverProviderModels = defaults.discoverProviderModels
|
||||
}
|
||||
if deps.detectLocalRuntimes == nil {
|
||||
deps.detectLocalRuntimes = defaults.detectLocalRuntimes
|
||||
}
|
||||
@@ -518,17 +493,6 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -544,21 +508,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
// A resolve failure the setup wizard can FIX is not fatal for the
|
||||
// interactive TUI: drop into the wizard with an empty config so the user
|
||||
// can onboard/repair, instead of exiting with an error they can only fix
|
||||
// by hand-editing config.json. That covers a missing/unresolvable active
|
||||
// provider and an active provider without a model (custom endpoints have
|
||||
// no catalog default) — previously the second shape bricked bare `zero`
|
||||
// and `zero setup`, the exact commands that could have fixed it. Any
|
||||
// other error (malformed JSON, directory conflict, etc.) is still fatal,
|
||||
// and headless commands (zero config / zero exec) still fail with the
|
||||
// actionable message.
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -691,7 +641,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
@@ -770,50 +719,20 @@ func tuiSandboxSetupCommand(backend sandbox.Backend, deps appDeps) func(context.
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider constructs the run's provider at STARTUP — it is called only from
|
||||
// the two launch paths (interactive TUI and headless exec), never from mid-run
|
||||
// rebuilds (escalation, wizard, ACP go through deps.newProvider directly).
|
||||
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return nil, nil
|
||||
}
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored key, so this
|
||||
// (and every other newProvider caller) needs no per-site key handling.
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pin spawned children (sub-agents / swarm members inherit the environment) to
|
||||
// THIS run's provider from launch, not only after an in-session switch. Without
|
||||
// the launch-time export a child re-resolves config.json at spawn time, so a
|
||||
// provider switch persisted by ANOTHER zero process mid-session would silently
|
||||
// move new children onto a different provider (and different credentials) than
|
||||
// the parent is running. Runtime switches (/model, /provider, wizard,
|
||||
// onboarding) re-export on commit, keeping the pin current. Injected via deps
|
||||
// (nil in tests) because it mutates the process environment.
|
||||
if deps.exportActiveProvider != nil {
|
||||
deps.exportActiveProvider(resolved.Provider.Name)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// applyStoredProviderKeyAt loads the API key from the encrypted credential store
|
||||
// when the resolver left it empty (the resolver is pure — no I/O — so an
|
||||
// apiKeyStored profile reaches this layer keyless). No-op for inline/env-key
|
||||
// providers and when nothing is stored. This is applied once, centrally, by the
|
||||
// deps.newProvider wrapper in fillAppDeps so EVERY surface that builds a runtime
|
||||
// provider (headless exec, the ACP builder, exec's mid-run escalation switcher,
|
||||
// and any future caller) is covered without a per-site invariant to forget.
|
||||
func applyStoredProviderKeyAt(profile config.ProviderProfile, userConfigPath func() (string, error)) config.ProviderProfile {
|
||||
if userConfigPath == nil {
|
||||
return profile
|
||||
}
|
||||
if path, perr := userConfigPath(); perr == nil {
|
||||
// Load the API key from the encrypted credential store when it isn't already
|
||||
// resolved from inline config or an env var. This keeps the resolver pure (no
|
||||
// I/O) and is a no-op for env/inline-key providers and when nothing is stored.
|
||||
profile := resolved.Provider
|
||||
if path, perr := deps.userConfigPath(); perr == nil {
|
||||
if store, err := config.ProviderKeyStoreAt(filepath.Dir(path)); err == nil {
|
||||
profile = config.ApplyStoredAPIKey(profile, store)
|
||||
}
|
||||
}
|
||||
return profile
|
||||
return deps.newProvider(profile)
|
||||
}
|
||||
|
||||
func newCoreRegistry(workspaceRoot string) *tools.Registry {
|
||||
@@ -1114,10 +1033,9 @@ func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
|
||||
return addDirs, args, nil
|
||||
}
|
||||
|
||||
// splitLeadingThemeFlag strips a leading --theme <auto|theme-name> (space or =form)
|
||||
// from the root argument list and validates it against the registered themes. The
|
||||
// last occurrence wins. A value outside the allowed set is a loud error rather than
|
||||
// a silent fallback.
|
||||
// splitLeadingThemeFlag strips a leading --theme {auto|dark|light} (space or =form)
|
||||
// from the root argument list and validates it. The last occurrence wins. A value
|
||||
// outside the allowed set is a loud error rather than a silent fallback.
|
||||
func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
theme := ""
|
||||
for len(args) > 0 {
|
||||
@@ -1125,7 +1043,7 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
switch {
|
||||
case args[0] == "--theme":
|
||||
if len(args) < 2 {
|
||||
return "", nil, errors.New("--theme requires a value (auto or a theme name; try --theme auto)")
|
||||
return "", nil, errors.New("--theme requires a value (auto, dark, or light)")
|
||||
}
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = args[2:]
|
||||
@@ -1135,10 +1053,12 @@ func splitLeadingThemeFlag(args []string) (string, []string, error) {
|
||||
default:
|
||||
return theme, args, nil
|
||||
}
|
||||
if !tui.ValidThemeArg(value) {
|
||||
return "", nil, fmt.Errorf("--theme must be auto or a registered theme name (got %q)", value)
|
||||
switch strings.ToLower(value) {
|
||||
case "auto", "dark", "light":
|
||||
theme = strings.ToLower(value)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("--theme must be auto, dark, or light (got %q)", value)
|
||||
}
|
||||
theme = strings.ToLower(value)
|
||||
}
|
||||
return theme, args, nil
|
||||
}
|
||||
|
||||
+1
-148
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -158,80 +157,6 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider should not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func 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
|
||||
@@ -1472,79 +1397,7 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
||||
if launched {
|
||||
t.Fatal("the TUI must not launch on a bad --theme value")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
|
||||
if !strings.Contains(stderr.String(), "--theme must be auto, dark, or light") {
|
||||
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A custom endpoint saved without a model previously bricked bare `zero` and
|
||||
// `zero setup` — the exact commands that could have fixed it (the resolve
|
||||
// error escaped before the wizard could open). The interactive TUI now treats
|
||||
// the requires-model failure as "needs onboarding", same as a missing active
|
||||
// provider. The error comes from a REAL Resolve over a real config file, so
|
||||
// this exercises the production sentinel wrapping, not a hand-built error.
|
||||
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(brokenConfig, []byte(`{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
launched := false
|
||||
var launchedOptions tui.Options
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("newProvider must not be called without a resolved provider")
|
||||
return nil, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
||||
}
|
||||
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
||||
}
|
||||
}
|
||||
|
||||
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
||||
// users try them (reported in the wild), so the unknown-command error points at
|
||||
// the real command.
|
||||
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
||||
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
||||
}
|
||||
// An unrelated unknown command gets no misleading suggestion.
|
||||
stderr.Reset()
|
||||
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
||||
if strings.Contains(stderr.String(), "did you mean") {
|
||||
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -283,9 +283,6 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
|
||||
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
|
||||
AllowPresets: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// seedStoredProviderKey writes key into a credential store rooted beside the
|
||||
// returned config path, and returns deps fields wired at that path — the same
|
||||
// layout applyStoredProviderKey resolves through in production.
|
||||
func seedStoredProviderKey(t *testing.T, providerName, key string) (userConfigPath string) {
|
||||
t.Helper()
|
||||
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file") // never touch the real OS keychain in tests
|
||||
dir := t.TempDir()
|
||||
store, err := config.ProviderKeyStoreAt(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderKeyStoreAt: %v", err)
|
||||
}
|
||||
if err := store.Set(providerName, key); err != nil {
|
||||
t.Fatalf("seed stored key: %v", err)
|
||||
}
|
||||
return filepath.Join(dir, "config.json")
|
||||
}
|
||||
|
||||
// applyStoredProviderKeyAt must fill APIKey from the credential store for an
|
||||
// apiKeyStored profile — the single central helper the newProvider wrapper uses.
|
||||
func TestApplyStoredProviderKeyAtFillsFromCredstore(t *testing.T) {
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-test")
|
||||
ucp := func() (string, error) { return configPath, nil }
|
||||
|
||||
stored := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo", APIKeyStored: true}, ucp)
|
||||
if stored.APIKey != "sk-stored-test" {
|
||||
t.Fatalf("APIKey = %q, want the stored key", stored.APIKey)
|
||||
}
|
||||
|
||||
// A profile that did NOT opt into stored-key auth must be left unchanged —
|
||||
// a stale store entry must not silently reactivate credentials.
|
||||
plain := applyStoredProviderKeyAt(config.ProviderProfile{Name: "echo"}, ucp)
|
||||
if plain.APIKey != "" {
|
||||
t.Fatalf("non-stored profile APIKey = %q, want empty", plain.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// fillAppDeps wraps newProvider so EVERY surface that builds a runtime provider
|
||||
// (buildProvider, the ACP builder — which is just deps.newProvider — and exec's
|
||||
// escalation switcher) gets the stored key applied. Passing the raw resolved
|
||||
// profile previously sent unauthenticated requests for apiKeyStored profiles,
|
||||
// the default onboarding outcome. Testing the wrap covers all those surfaces.
|
||||
func TestFillAppDepsWrapsNewProviderWithStoredKey(t *testing.T) {
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-wrap")
|
||||
|
||||
var captured config.ProviderProfile
|
||||
deps := fillAppDeps(appDeps{
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
captured = profile
|
||||
return nil, nil // only the captured profile matters here
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := deps.newProvider(config.ProviderProfile{Name: "echo", APIKeyStored: true}); err != nil {
|
||||
t.Fatalf("wrapped newProvider: %v", err)
|
||||
}
|
||||
if captured.APIKey != "sk-stored-wrap" {
|
||||
t.Fatalf("wrapped newProvider built with APIKey = %q, want the stored key (unauthenticated regression)", captured.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// buildProvider (the TUI/exec STARTUP construction site) must export
|
||||
// ZERO_PROVIDER so children spawned at any point in the run are pinned to the
|
||||
// parent's provider from launch — not only after an in-session switch. Without
|
||||
// this, a provider switch persisted by another zero process mid-session moves
|
||||
// new children onto a different provider (and credentials) than the parent.
|
||||
func TestBuildProviderExportsActiveProviderEnv(t *testing.T) {
|
||||
t.Setenv(config.ActiveProviderEnv, "stale-from-elsewhere")
|
||||
// The exporter is injected (defaultAppDeps wires config.SetActiveProviderEnv;
|
||||
// fillAppDeps deliberately leaves it nil so ordinary tests never mutate the
|
||||
// process env). Inject the real one here to assert the actual env effect.
|
||||
deps := appDeps{
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
resolved := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "pinned", ProviderKind: config.ProviderKindAnthropic, Model: "m"}}
|
||||
if _, err := buildProvider(resolved, deps); err != nil {
|
||||
t.Fatalf("buildProvider: %v", err)
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "pinned" {
|
||||
t.Fatalf("%s = %q after startup build, want %q (children would spawn on the stale provider)", config.ActiveProviderEnv, got, "pinned")
|
||||
}
|
||||
|
||||
// A FAILED build must not move the pin: the process never committed to the
|
||||
// new provider, so children must keep resolving whatever was in effect.
|
||||
t.Setenv(config.ActiveProviderEnv, "still-current")
|
||||
failing := appDeps{
|
||||
exportActiveProvider: config.SetActiveProviderEnv,
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
}
|
||||
if _, err := buildProvider(resolved, failing); err == nil {
|
||||
t.Fatal("expected build error")
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
|
||||
t.Fatalf("%s = %q after failed build, want it untouched", config.ActiveProviderEnv, got)
|
||||
}
|
||||
|
||||
// No provider profile at all → no provider, no pin change.
|
||||
if _, err := buildProvider(config.ResolvedConfig{}, deps); err != nil {
|
||||
t.Fatalf("buildProvider(empty): %v", err)
|
||||
}
|
||||
if got := os.Getenv(config.ActiveProviderEnv); got != "still-current" {
|
||||
t.Fatalf("%s = %q after profile-less build, want it untouched", config.ActiveProviderEnv, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A mid-run --allow-escalation model switch previously rebuilt the provider
|
||||
// from the PURE resolved profile (the stored key lives only in buildProvider's
|
||||
// local copy), so the escalated provider was keyless and the next completion
|
||||
// call 401'd mid-run. Every profile handed to newProvider on this path must
|
||||
// carry the stored key.
|
||||
func TestRunExecEscalationSwitchKeepsStoredKey(t *testing.T) {
|
||||
dataHome := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dataHome)
|
||||
cwd := t.TempDir()
|
||||
|
||||
if _, ok := mustUpgradeTarget(t, "claude-haiku-4.5"); !ok {
|
||||
t.Skip("registry has no upgrade target for claude-haiku-4.5")
|
||||
}
|
||||
|
||||
configPath := seedStoredProviderKey(t, "echo", "sk-stored-escalation")
|
||||
|
||||
var builtProfiles []config.ProviderProfile
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{
|
||||
"exec",
|
||||
"--allow-escalation",
|
||||
"--model", "claude-haiku-4.5",
|
||||
"--init-session-id", "escalation_stored_key_run",
|
||||
"escalate please",
|
||||
}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
userConfigPath: func() (string, error) { return configPath, nil },
|
||||
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "claude-haiku-4.5"
|
||||
if overrides.Provider.Model != "" {
|
||||
model = overrides.Provider.Model
|
||||
}
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
|
||||
cfg.Provider.Model = model
|
||||
cfg.Provider.APIKeyStored = true // key lives ONLY in the credstore
|
||||
cfg.MaxTurns = 3
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
builtProfiles = append(builtProfiles, profile)
|
||||
return &usageEmittingEscalatingProvider{escalate: len(builtProfiles) == 1}, nil
|
||||
},
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want success; stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if len(builtProfiles) < 2 {
|
||||
t.Fatalf("want at least 2 provider builds (initial + escalated), got %d", len(builtProfiles))
|
||||
}
|
||||
for i, profile := range builtProfiles {
|
||||
if profile.APIKey != "sk-stored-escalation" {
|
||||
t.Fatalf("provider build %d APIKey = %q, want the stored key (escalated build went out keyless)", i, profile.APIKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,6 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if command == "detect" {
|
||||
return runProvidersDetect(args, stdout, stderr, deps)
|
||||
}
|
||||
if command == "models" {
|
||||
return runProvidersModels(args, stdout, stderr, deps)
|
||||
}
|
||||
if command != "list" && command != "current" && command != "catalog" {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
||||
}
|
||||
@@ -468,11 +465,9 @@ func writeProvidersHelp(w io.Writer) error {
|
||||
zero providers use <name> [flags]
|
||||
zero providers setup <catalog-id> [flags]
|
||||
zero providers detect [flags]
|
||||
zero providers models [name] [flags]
|
||||
|
||||
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
||||
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
||||
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
||||
|
||||
Flags:
|
||||
--json Print JSON summary
|
||||
|
||||
@@ -365,9 +365,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher = func(_ context.Context, modelID string) (agent.Provider, error) {
|
||||
// deps.newProvider is wrapped (fillAppDeps) to apply the stored key, so
|
||||
// the escalated provider is authenticated even though resolved.Provider
|
||||
// is the pure profile — no per-site key handling here.
|
||||
switchedProfile := resolved.Provider
|
||||
switchedProfile.Model = modelID
|
||||
switchedProvider, err := deps.newProvider(switchedProfile)
|
||||
|
||||
@@ -24,7 +24,6 @@ func TestProviderHasOAuthLogin(t *testing.T) {
|
||||
|
||||
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
@@ -46,88 +45,3 @@ func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
|
||||
t.Fatal("a keyless provider with no OAuth login must require onboarding")
|
||||
}
|
||||
}
|
||||
|
||||
// A profile renamed away from its catalog ID must still get a runtime OAuth
|
||||
// resolver when the login is stored under the catalog name — and a CASE-VARIANT
|
||||
// profile name must not drop the catalog-ID candidate (the store is
|
||||
// case-sensitive; a case-insensitive dedupe would swallow it).
|
||||
func TestOAuthResolverForProfileFallsBackToCatalogID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
// A renamed profile resolves the catalog-ID login, and the returned key MUST be
|
||||
// that login's key — the same key the Codex account-header resolver reads from,
|
||||
// so bearer and account can never come from different logins.
|
||||
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"}); resolver == nil {
|
||||
t.Fatal("renamed profile with a catalog-ID login must get a resolver (unauthenticated-children regression)")
|
||||
} else if key != oauth.ProviderKey("chatgpt") {
|
||||
t.Fatalf("bound key = %q, want the chatgpt login key (bearer/account must share it)", key)
|
||||
}
|
||||
// Name differs from catalog ID only in CASE: the exact-case store key is
|
||||
// "provider:chatgpt", so "chatgpt" must survive as a distinct candidate.
|
||||
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"}); resolver == nil {
|
||||
t.Fatal("case-variant profile name must still resolve the catalog-ID login (case-insensitive dedupe regression)")
|
||||
} else if key != oauth.ProviderKey("chatgpt") {
|
||||
t.Fatalf("case-variant bound key = %q, want the chatgpt login key", key)
|
||||
}
|
||||
if resolver, _ := oauthLoginForProfile(config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"}); resolver == nil {
|
||||
t.Fatal("catalog-named profile must get a resolver")
|
||||
}
|
||||
if resolver, key := oauthLoginForProfile(config.ProviderProfile{Name: "openai", CatalogID: "openai"}); resolver != nil || key != "" {
|
||||
t.Fatalf("a profile with no stored login must get no resolver and an empty key, got key=%q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog-ID fallback must NOT reach a sibling profile's OAuth login when
|
||||
// this profile has its own key — withBearer would erase the key and silently
|
||||
// bill the model call under the sibling's account (a real cross-profile
|
||||
// override the fallback introduced). A profile WITH its own resolved key gets no
|
||||
// resolver even when a catalog-shared login exists.
|
||||
func TestOAuthResolverForProfileDoesNotOverrideExplicitKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json")
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
// A personal login stored under the catalog name.
|
||||
if err := store.Save(oauth.ProviderKey("anthropic"), oauth.Token{AccessToken: "personal-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
// Work profile: same catalog, its OWN resolved API key, deliberately NOT the
|
||||
// personal OAuth account. It must keep using its key (no resolver attached).
|
||||
work := config.ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work-key"}
|
||||
if resolver, key := oauthLoginForProfile(work); resolver != nil || key != "" {
|
||||
t.Fatalf("a profile with its own API key must not get a catalog-shared OAuth resolver (cross-profile override), got key=%q", key)
|
||||
}
|
||||
// A raw auth-header credential must be protected the same way.
|
||||
header := config.ProviderProfile{Name: "anthropic-hdr", CatalogID: "anthropic", AuthHeaderValue: "Bearer byo"}
|
||||
if resolver, _ := oauthLoginForProfile(header); resolver != nil {
|
||||
t.Fatal("a profile with its own auth header must not get a catalog-shared OAuth resolver")
|
||||
}
|
||||
// A same-NAME login must not override an explicit key either (the name
|
||||
// candidate is gated on the profile being keyless, not just the catalog one).
|
||||
if err := store.Save(oauth.ProviderKey("anthropic-work"), oauth.Token{AccessToken: "name-tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed name token: %v", err)
|
||||
}
|
||||
if resolver, _ := oauthLoginForProfile(work); resolver != nil {
|
||||
t.Fatal("a profile with its own API key must not get an OAuth resolver even from a same-name login")
|
||||
}
|
||||
// Sanity: a keyless sibling on the same catalog DOES resolve the shared login.
|
||||
keyless := config.ProviderProfile{Name: "anthropic-oauth", CatalogID: "anthropic"}
|
||||
if resolver, key := oauthLoginForProfile(keyless); resolver == nil {
|
||||
t.Fatal("a keyless profile should still resolve the catalog-shared login")
|
||||
} else if key != oauth.ProviderKey("anthropic") {
|
||||
t.Fatalf("keyless sibling bound key = %q, want the catalog login key", key)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -11,48 +12,42 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
)
|
||||
|
||||
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
|
||||
// returns both a TokenResolver that authenticates model calls with it and the
|
||||
// credential-store key it bound to. It returns (nil, "") when no login exists —
|
||||
// keeping API-key users free of any per-request store lookups, since the resolver
|
||||
// is only attached when a login is present at construction time.
|
||||
//
|
||||
// The returned key is the single source of truth for "which login is this
|
||||
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
|
||||
// Codex chatgpt-account-id header reads its account from the exact login that
|
||||
// issued the bearer token, instead of doing a second, independent lookup that
|
||||
// could select a different login (a backend-rejected mismatch).
|
||||
//
|
||||
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
|
||||
// the profile having no own configured credential) come from the shared
|
||||
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
|
||||
// resolver, and the onboarding presence check never diverge.
|
||||
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
||||
candidates := profile.OAuthLoginCandidates()
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
// providerOAuthName resolves the OAuth login name for a provider profile. It is
|
||||
// the name a user logs in under (`zero auth login <name>`) whose token then
|
||||
// authenticates this provider's model calls. For now it is the profile name;
|
||||
// the provider presets map well-known kinds (openai/anthropic) to canonical
|
||||
// names on top of this.
|
||||
func providerOAuthName(profile config.ProviderProfile) string {
|
||||
return strings.TrimSpace(profile.Name)
|
||||
}
|
||||
|
||||
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
|
||||
// model calls with the user's OAuth login, or nil when no login exists for this
|
||||
// provider. Returning nil for the no-login case keeps API-key users free of any
|
||||
// per-request store lookups: the resolver is only attached once a login is
|
||||
// present at construction time.
|
||||
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
|
||||
name := providerOAuthName(profile)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
return nil
|
||||
}
|
||||
_, key, ok := oauth.FirstStored(store, candidates)
|
||||
if !ok {
|
||||
// No login under any candidate (or unreadable/invalid keys) → API-key
|
||||
// auth, no resolver.
|
||||
return nil, ""
|
||||
key := oauth.ProviderKey(name)
|
||||
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
|
||||
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
|
||||
return nil
|
||||
}
|
||||
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
// Refreshing a token the user logged into (possibly a preset provider like
|
||||
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
||||
AllowPresets: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
return nil
|
||||
}
|
||||
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
||||
var token string
|
||||
var rerr error
|
||||
if forceRefresh {
|
||||
@@ -69,5 +64,4 @@ func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResol
|
||||
}
|
||||
return "Authorization", "Bearer " + token, true, nil
|
||||
}
|
||||
return resolver, key
|
||||
}
|
||||
@@ -18,7 +18,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}{
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
@@ -47,21 +46,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "longcat")
|
||||
|
||||
for _, want := range []string{
|
||||
"requiresAuth=true",
|
||||
"authEnvVars=LONGCAT_API_KEY",
|
||||
"setup: zero providers setup longcat --set-active",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
type providerModelsOptions struct {
|
||||
name string
|
||||
json bool
|
||||
}
|
||||
|
||||
// runProvidersModels lists the models a saved provider actually serves by probing
|
||||
// its live model-discovery endpoint (e.g. an OpenAI-compatible `/v1/models`). It
|
||||
// works for custom OpenAI-/Anthropic-compatible providers too: discovery runs off
|
||||
// the profile's base URL + credentials, so a self-hosted endpoint serving a dozen
|
||||
// models no longer needs a config object per model — configure the provider once,
|
||||
// then run any listed model with `zero exec --model <id>` (Zero passes unknown
|
||||
// model ids through to the provider).
|
||||
func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseProviderModelsArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeProvidersHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
return exitCode
|
||||
}
|
||||
profile, err := selectProviderForCheck(resolved, options.name)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
models, err := deps.discoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitProvider)
|
||||
}
|
||||
|
||||
if options.json {
|
||||
items := make([]map[string]any, 0, len(models))
|
||||
for _, model := range models {
|
||||
entry := map[string]any{"id": model.ID}
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
entry["description"] = description
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"provider": profile.Name,
|
||||
"count": len(items),
|
||||
"models": items,
|
||||
}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(profile.Name)
|
||||
if name == "" {
|
||||
name = "provider"
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Provider models (%s)\n", name); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
for _, model := range models {
|
||||
line := strings.TrimSpace(model.ID)
|
||||
if description := strings.TrimSpace(model.Description); description != "" {
|
||||
line += " — " + description
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, line); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
suffix := "s"
|
||||
if len(models) == 1 {
|
||||
suffix = ""
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "%d model%s discovered\n", len(models), suffix); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if len(models) > 0 {
|
||||
if _, err := fmt.Fprintf(stdout, "next: zero exec %q --model %s\n", "hello", setupCommandArg(models[0].ID)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// discoveryCredentialProfile resolves the profile's API key the same way the
|
||||
// runtime does — inline, then the stored credential, then the configured env var —
|
||||
// so a `providers models` probe authenticates exactly like a real request. Mirrors
|
||||
// discoveredModelContextWindow's credential resolution.
|
||||
func discoveryCredentialProfile(profile config.ProviderProfile) config.ProviderProfile {
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
return authed
|
||||
}
|
||||
|
||||
// defaultDiscoverProviderModels is the production discovery hook: a live probe of
|
||||
// the provider's model-listing endpoint with no curated-catalog merge or
|
||||
// coding-model filtering, so a custom provider's full model list is returned.
|
||||
func defaultDiscoverProviderModels(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return providermodeldiscovery.Discover(ctx, profile, providermodeldiscovery.Options{})
|
||||
}
|
||||
|
||||
func parseProviderModelsArgs(args []string) (providerModelsOptions, bool, error) {
|
||||
options := providerModelsOptions{}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
return options, true, nil
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
||||
default:
|
||||
if options.name != "" {
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
||||
}
|
||||
options.name = arg
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func TestRunProvidersModelsListsDiscoveredModels(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: "team/model-a", Description: "first"},
|
||||
{ID: "team/model-b"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
// No name given → the active provider is probed, with its resolved credential.
|
||||
if probed.Name != "work" {
|
||||
t.Fatalf("probed provider = %q, want active provider work", probed.Name)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"Provider models (work)", "team/model-a — first", "team/model-b", "2 models discovered", "--model team/model-a"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsJSON(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "m1", Description: "d1"}, {ID: "m2"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "--json"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Provider string `json:"provider"`
|
||||
Count int `json:"count"`
|
||||
Models []struct {
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode json: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.Provider != "work" || payload.Count != 2 || len(payload.Models) != 2 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if payload.Models[0].ID != "m1" || payload.Models[0].Description != "d1" {
|
||||
t.Fatalf("model[0] = %#v", payload.Models[0])
|
||||
}
|
||||
// A model with no description omits the field rather than emitting an empty string.
|
||||
if payload.Models[1].ID != "m2" || payload.Models[1].Description != "" {
|
||||
t.Fatalf("model[1] = %#v", payload.Models[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSelectsNamedProvider(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
custom := config.ProviderProfile{
|
||||
Name: "self-hosted",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://models.internal/v1",
|
||||
APIKey: "sk-internal",
|
||||
Model: "house-model",
|
||||
}
|
||||
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
work := config.ProviderProfile{Name: "work", ProviderKind: config.ProviderKindOpenAI, BaseURL: config.OpenAIBaseURL, APIKey: "sk", Model: "gpt-4.1"}
|
||||
return config.ResolvedConfig{ActiveProvider: "work", Provider: work, Providers: []config.ProviderProfile{work, custom}}, nil
|
||||
}
|
||||
var probed config.ProviderProfile
|
||||
deps.discoverProviderModels = func(_ context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
probed = profile
|
||||
return []providermodeldiscovery.Model{{ID: "house-model"}}, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "self-hosted"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exit = %d, want %d: %s", exitCode, exitSuccess, stderr.String())
|
||||
}
|
||||
if probed.Name != "self-hosted" || probed.BaseURL != "https://models.internal/v1" {
|
||||
t.Fatalf("probed = %#v, want the named custom provider", probed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsUnknownProviderFails(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
called := false
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models", "ghost"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("exit = %d, want non-zero for unknown provider", exitCode)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("discovery must not run for an unknown provider")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `provider "ghost" not found`) {
|
||||
t.Fatalf("stderr = %q, want provider-not-found", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProvidersModelsSurfacesDiscoveryError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
deps := commandCenterDeps(t)
|
||||
deps.discoverProviderModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, errors.New("models endpoint returned 404")
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{"providers", "models"}, &stdout, &stderr, deps)
|
||||
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exit = %d, want exitProvider %d", exitCode, exitProvider)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "models endpoint returned 404") {
|
||||
t.Fatalf("stderr = %q, want the discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -488,7 +488,7 @@ func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
}
|
||||
|
||||
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
||||
return profile.HasConfiguredCredential()
|
||||
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
func providerKindForDescriptor(descriptor providercatalog.Descriptor) config.ProviderKind {
|
||||
|
||||
@@ -314,14 +314,10 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
}
|
||||
|
||||
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
|
||||
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
|
||||
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
|
||||
// runtime" resolver can never diverge. This is only consulted for a profile with
|
||||
// no usable API-key credential (setupRequired returns early otherwise), where
|
||||
// the candidate set is permissive (profile name + catalog ID).
|
||||
// provider, keyed by its profile name or catalog id.
|
||||
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
||||
for _, name := range profile.OAuthLoginCandidates() {
|
||||
if oauthLogins[name] {
|
||||
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
|
||||
if name != "" && oauthLogins[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestRunSpecialistListShowAndPath(t *testing.T) {
|
||||
@@ -328,7 +326,7 @@ func setSpecialistConfigRoot(t *testing.T) string {
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
configRoot, err := config.UserConfigDir()
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -155,63 +155,6 @@ func MigratePlaintextProviderKeys(path string, store APIKeySetter) (int, error)
|
||||
return migrated, nil
|
||||
}
|
||||
|
||||
// HasConfiguredCredential reports whether the profile is set up to authenticate
|
||||
// with its own API key (inline, stored, or a raw auth header). It is the single
|
||||
// definition of "this profile is key-authed", shared by OAuthLoginCandidates and
|
||||
// the cli/tui setup surfaces so they never disagree about whether a profile is
|
||||
// keyed. APIKeyEnv is deliberately NOT included: an env var may be unset at
|
||||
// runtime while the profile actually relies on an OAuth login, so an
|
||||
// env-configured-but-empty profile must still be allowed to resolve a token.
|
||||
func (profile ProviderProfile) HasConfiguredCredential() bool {
|
||||
return strings.TrimSpace(profile.APIKey) != "" ||
|
||||
profile.APIKeyStored ||
|
||||
strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
// OAuthLoginCandidates returns the login names to try, in order, when resolving
|
||||
// this profile's OAuth token — used by the runtime bearer resolver, the Codex
|
||||
// account-header resolver, and the onboarding presence check so all three agree.
|
||||
//
|
||||
// It returns NO candidates for a profile that carries its own configured
|
||||
// credential: attaching an OAuth resolver to such a profile makes withBearer
|
||||
// erase the key the instant a token resolves, silently routing/billing the call
|
||||
// through the OAuth account instead of the intended key (the cross-profile
|
||||
// override the review caught — and, via the profile name, a same-name login
|
||||
// overriding an explicit key). A stored-key profile that reaches this keyless
|
||||
// (e.g. a transiently unreadable keyring) is likewise treated as key-auth, so it
|
||||
// fails with a clear missing-credential error rather than silently borrowing an
|
||||
// unrelated OAuth login. APIKeyEnv is intentionally not part of that gate (an
|
||||
// env var may be unset while the profile relies on OAuth).
|
||||
//
|
||||
// For a keyless profile the profile name comes first (a login under your own
|
||||
// profile name is unambiguously yours), then the catalog ID as a FALLBACK (so a
|
||||
// profile renamed away from its catalog ID — e.g. {name:"codex",
|
||||
// catalogID:"chatgpt"} — still finds a `zero auth login chatgpt` token).
|
||||
// Candidates are trimmed, blank-skipped, and de-duplicated CASE-SENSITIVELY — the
|
||||
// OAuth token store is a case-sensitive map, so "ChatGPT" and "chatgpt" are
|
||||
// distinct keys and both must survive.
|
||||
func (profile ProviderProfile) OAuthLoginCandidates() []string {
|
||||
if profile.HasConfiguredCredential() {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
add := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
for _, existing := range names {
|
||||
if existing == s { // case-sensitive: the store lookup is case-sensitive
|
||||
return
|
||||
}
|
||||
}
|
||||
names = append(names, s)
|
||||
}
|
||||
add(profile.Name)
|
||||
add(profile.CatalogID)
|
||||
return names
|
||||
}
|
||||
|
||||
// ApplyStoredAPIKey fills profile.APIKey from the credential store when it is not
|
||||
// already resolved — an inline config key or a resolved APIKeyEnv always wins, so
|
||||
// this only supplies a key for providers whose secret lives in the store. A nil
|
||||
|
||||
@@ -172,69 +172,3 @@ func TestProviderProfileAPIKeyStoredRoundTrips(t *testing.T) {
|
||||
t.Fatalf("no inline key expected, got %q", p.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthLoginCandidates always offers the profile name, adds the catalog ID as a
|
||||
// fallback ONLY when the profile has no effective own credential, and dedupes
|
||||
// case-sensitively (the OAuth store is a case-sensitive map).
|
||||
func TestOAuthLoginCandidates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
profile ProviderProfile
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "renamed keyless profile falls back to catalog id",
|
||||
profile: ProviderProfile{Name: "codex", CatalogID: "chatgpt"},
|
||||
want: []string{"codex", "chatgpt"},
|
||||
},
|
||||
{
|
||||
name: "case-variant name keeps distinct catalog-id candidate",
|
||||
profile: ProviderProfile{Name: "ChatGPT", CatalogID: "chatgpt"},
|
||||
want: []string{"ChatGPT", "chatgpt"},
|
||||
},
|
||||
{
|
||||
name: "exact-duplicate name and catalog id collapse",
|
||||
profile: ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
|
||||
want: []string{"chatgpt"},
|
||||
},
|
||||
{
|
||||
// A configured key must block ALL candidates (name included): a login
|
||||
// under the profile's own name would otherwise erase the key too.
|
||||
name: "own inline key blocks every candidate",
|
||||
profile: ProviderProfile{Name: "anthropic-work", CatalogID: "anthropic", APIKey: "sk-work"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "own auth header blocks every candidate",
|
||||
profile: ProviderProfile{Name: "acme", CatalogID: "anthropic", AuthHeaderValue: "Bearer x"},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// APIKeyStored means key-auth even if the key isn't loaded here (e.g. a
|
||||
// transiently unreadable keyring): don't silently borrow an OAuth login.
|
||||
name: "stored-key profile blocks every candidate",
|
||||
profile: ProviderProfile{Name: "openai-stored", CatalogID: "openai", APIKeyStored: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// APIKeyEnv is NOT a configured credential for gating: the env var may be
|
||||
// unset while the profile relies on an OAuth login, so candidates stand.
|
||||
name: "env-only profile still yields candidates",
|
||||
profile: ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
|
||||
want: []string{"xai"},
|
||||
},
|
||||
{
|
||||
name: "empty profile yields no candidates",
|
||||
profile: ProviderProfile{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.profile.OAuthLoginCandidates()
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Fatalf("OAuthLoginCandidates() = %#v, want %#v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -34,32 +33,13 @@ func DefaultResolveOptions(workspaceRoot string) (ResolveOptions, error) {
|
||||
}
|
||||
|
||||
func DefaultUserConfigPath() (string, error) {
|
||||
userConfigDir, err := UserConfigDir()
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve user config directory: %w", err)
|
||||
}
|
||||
return filepath.Join(userConfigDir, "zero", "config.json"), nil
|
||||
}
|
||||
|
||||
// UserConfigDir returns the base directory Zero stores per-user config under.
|
||||
// It mirrors os.UserConfigDir everywhere except macOS: there Go defaults to
|
||||
// ~/Library/Application Support, but Zero deliberately uses ~/.config (XDG-style,
|
||||
// matching Linux and Claude Code) so a single config path works cross-platform.
|
||||
// $XDG_CONFIG_HOME still wins on macOS when set.
|
||||
func UserConfigDir() (string, error) {
|
||||
if runtime.GOOS == "darwin" {
|
||||
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
|
||||
return xdg, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".config"), nil
|
||||
}
|
||||
return os.UserConfigDir()
|
||||
}
|
||||
|
||||
func existingConfigFile(path string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -105,7 +105,7 @@ func setUserConfigRoot(t *testing.T) string {
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := UserConfigDir()
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
@@ -130,21 +130,3 @@ func mkdirAll(t *testing.T, path string) {
|
||||
t.Fatalf("create directory %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultUserConfigPathUsesXDGConfigOnMacOS(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("macOS-specific config path behavior")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
path, err := DefaultUserConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultUserConfigPath() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(home, ".config", "zero", "config.json")
|
||||
if path != want {
|
||||
t.Fatalf("DefaultUserConfigPath() = %q, want %q", path, want)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -15,31 +14,6 @@ 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.
|
||||
@@ -221,9 +195,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -246,17 +217,8 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
// are the only config sources for write roots.
|
||||
//
|
||||
// Sandbox.Network from project config may only TIGHTEN (→ "deny"), never
|
||||
// WEAKEN (→ "allow"). A malicious repo must not be able to open network
|
||||
// access for shell commands. Matches the AdditionalWriteRoots posture.
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
// Normalize: anything that isn't literally "allow" counts as deny.
|
||||
if sandbox.NormalizeNetworkMode(sandbox.NetworkMode(network)) != sandbox.NetworkAllow {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
// Silently ignore "allow" from project config — not an error, just
|
||||
// a privilege the project scope does not have.
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
if src.Sandbox.BlockUnixSockets {
|
||||
dst.Sandbox.BlockUnixSockets = true
|
||||
@@ -405,18 +367,6 @@ func effectiveProviderKind(profile ProviderProfile) ProviderKind {
|
||||
return ""
|
||||
}
|
||||
|
||||
// catalogDefaultModel returns the default model of a catalog provider ("" when
|
||||
// the id is unknown), used to fill a missing profile.Model for the official-API
|
||||
// kinds so a hand-written profile without a model resolves instead of bricking
|
||||
// every command that resolves config up front (zero config, bare zero setup).
|
||||
func catalogDefaultModel(catalogID string) string {
|
||||
descriptor, ok := providercatalog.Get(catalogID)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(descriptor.DefaultModel)
|
||||
}
|
||||
|
||||
func catalogDescriptorForProfile(profile ProviderProfile) (providercatalog.Descriptor, bool) {
|
||||
catalogID := providercatalog.NormalizeID(profile.CatalogID)
|
||||
if catalogID == "" {
|
||||
@@ -778,16 +728,11 @@ func unionStrings(base []string, extra []string) []string {
|
||||
}
|
||||
|
||||
type normalizeOptions struct {
|
||||
// defaultModels fills a missing profile.Model for the official-API provider
|
||||
// kinds (openai from the model registry; anthropic/google from their catalog
|
||||
// descriptors). Off for provider-command configs (command.go), which must
|
||||
// surface exactly what the external command returned rather than inventing
|
||||
// a model behind its back.
|
||||
defaultModels bool
|
||||
defaultOpenAIModel bool
|
||||
}
|
||||
|
||||
func normalizeProviders(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultModels: true}, envMaps...)
|
||||
return normalizeProvidersWithOptions(providers, activeName, normalizeOptions{defaultOpenAIModel: true}, envMaps...)
|
||||
}
|
||||
|
||||
func normalizeProvidersWithoutModelDefaults(providers []ProviderProfile, activeName string, envMaps ...map[string]string) ([]ProviderProfile, ProviderProfile, error) {
|
||||
@@ -802,7 +747,7 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
if activeName != "" {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
}
|
||||
return []ProviderProfile{}, ProviderProfile{}, nil
|
||||
}
|
||||
@@ -834,13 +779,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
return nil, ProviderProfile{}, fmt.Errorf("active provider %q not found", activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, &setupFixableError{
|
||||
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
|
||||
sentinel: ErrProviderRequiresModel,
|
||||
}
|
||||
return nil, ProviderProfile{}, providerError(active, "provider %s requires model", active.Name)
|
||||
}
|
||||
|
||||
return normalized, active, nil
|
||||
@@ -887,7 +829,7 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindOpenAI:
|
||||
if profile.BaseURL == "" || isOfficialOpenAIBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = OpenAIBaseURL
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
if options.defaultOpenAIModel && profile.Model == "" {
|
||||
profile.Model = modelregistry.DefaultModelID
|
||||
}
|
||||
return profile, nil
|
||||
@@ -904,9 +846,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindAnthropic:
|
||||
if profile.BaseURL == "" || isOfficialAnthropicBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = AnthropicBaseURL
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = catalogDefaultModel("anthropic")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
return ProviderProfile{}, providerError(profile, "anthropic provider %s requires official baseURL %s", profile.Name, AnthropicBaseURL)
|
||||
@@ -921,9 +860,6 @@ func normalizeProvider(profile ProviderProfile, env map[string]string, options n
|
||||
case ProviderKindGoogle:
|
||||
if profile.BaseURL == "" || isOfficialGoogleBaseURL(profile.BaseURL) {
|
||||
profile.BaseURL = GoogleBaseURL
|
||||
if options.defaultModels && profile.Model == "" {
|
||||
profile.Model = catalogDefaultModel("google")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
return ProviderProfile{}, providerError(profile, "google provider %s requires official baseURL %s", profile.Name, GoogleBaseURL)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -899,9 +898,6 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), `active provider "ghost" not found`) {
|
||||
t.Fatalf("error = %q, want active provider missing message", err.Error())
|
||||
}
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||
}
|
||||
if resolved.ActiveProvider != "" {
|
||||
t.Fatalf("ActiveProvider = %q, want empty", resolved.ActiveProvider)
|
||||
}
|
||||
@@ -1059,130 +1055,6 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "mini-cn",
|
||||
"providers": [{
|
||||
"name": "mini-cn",
|
||||
"catalog_id": "minimaxi-cn"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"MINIMAXI_API_KEY": "sk-cn-mini",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "minimaxi-cn" {
|
||||
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
|
||||
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "MiniMax-M3" {
|
||||
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-cn-mini" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
|
||||
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
|
||||
// for users who still target the China hosting; "zai" itself now points at
|
||||
// api.z.ai (international). The two env-vars are intentionally distinct
|
||||
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
|
||||
// one side cannot be silently used against the other.
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "zai-cn",
|
||||
"providers": [{
|
||||
"name": "zai-cn",
|
||||
"catalog_id": "zai-cn"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"ZHIPU_API_KEY": "sk-zhipu-cn",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "zai-cn" {
|
||||
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
|
||||
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "glm-4.5" {
|
||||
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-zhipu-cn" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
|
||||
// "zai" now resolves to the international endpoint; the catalog default
|
||||
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "zai-intl",
|
||||
"providers": [{
|
||||
"name": "zai-intl",
|
||||
"catalog_id": "zai"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"ZAI_API_KEY": "sk-zai-intl",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "zai" {
|
||||
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
|
||||
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "glm-4.5" {
|
||||
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-zai-intl" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "local",
|
||||
@@ -1397,45 +1269,6 @@ func TestResolveSandboxBlockUnixSocketsFromFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||
// 1. Project config tries to set network to "allow" (should be ignored).
|
||||
userPath := writeConfig(t, `{}`)
|
||||
projectPath := writeConfig(t, `{
|
||||
"sandbox": {"network": "allow"}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProjectConfigPath: projectPath,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Network == "allow" {
|
||||
t.Fatal("resolved.Sandbox.Network = allow, want empty/deny to prevent project config from weakening sandbox")
|
||||
}
|
||||
|
||||
// 2. User config sets network to "allow", project config sets it to "deny" (tightening is allowed).
|
||||
userPath2 := writeConfig(t, `{
|
||||
"sandbox": {"network": "allow"}
|
||||
}`)
|
||||
projectPath2 := writeConfig(t, `{
|
||||
"sandbox": {"network": "deny"}
|
||||
}`)
|
||||
resolved2, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath2,
|
||||
ProjectConfigPath: projectPath2,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved2.Sandbox.Network != "deny" {
|
||||
t.Fatalf("resolved.Sandbox.Network = %q, want deny (project config allowed to tighten)", resolved2.Sandbox.Network)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
@@ -1718,97 +1551,3 @@ func TestResolveMaxTurnsZeroFallsBackToDefault(t *testing.T) {
|
||||
t.Fatalf("MaxTurns = %d, want default %d", resolved.MaxTurns, defaultMaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported brick: a hand-written google profile with an apiKey but no
|
||||
// model made EVERY resolving command fail ("provider google requires model"),
|
||||
// including zero config and bare zero setup — the only commands that could
|
||||
// have fixed it. Official-API kinds now fall back to their catalog default
|
||||
// model, exactly like the openai kind always has.
|
||||
func TestResolveDefaultsGoogleModelFromCatalog(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want model defaulted from the google catalog", err)
|
||||
}
|
||||
if resolved.Provider.Model != "gemini-2.5-pro" {
|
||||
t.Fatalf("Model = %q, want the google catalog default gemini-2.5-pro", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.BaseURL != GoogleBaseURL {
|
||||
t.Fatalf("BaseURL = %q, want %q", resolved.Provider.BaseURL, GoogleBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsAnthropicModelFromCatalog(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "anthropic",
|
||||
"providers": [{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want model defaulted from the anthropic catalog", err)
|
||||
}
|
||||
if resolved.Provider.Model != "claude-sonnet-4.5" {
|
||||
t.Fatalf("Model = %q, want the anthropic catalog default claude-sonnet-4.5", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly configured model must always win over the catalog default.
|
||||
func TestResolveKeepsExplicitGoogleModel(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [{"name": "google", "provider_kind": "google", "apiKey": "AIza-x", "model": "gemini-2.5-flash"}]
|
||||
}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.Model != "gemini-2.5-flash" {
|
||||
t.Fatalf("Model = %q, want the explicitly configured gemini-2.5-flash", resolved.Provider.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// Provider-command configs must NOT get invented models: the without-defaults
|
||||
// path surfaces exactly what the external command returned.
|
||||
func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
|
||||
_, _, err := normalizeProvidersWithoutModelDefaults(
|
||||
[]ProviderProfile{{Name: "google", ProviderKind: ProviderKindGoogle, APIKey: "AIza-x"}},
|
||||
"google", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("provider-command path must still require an explicit model")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires model") {
|
||||
t.Fatalf("error = %q, want requires-model", err.Error())
|
||||
}
|
||||
// The residual error must tell the user how to fix it.
|
||||
if !strings.Contains(err.Error(), "config.json") {
|
||||
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// The residual requires-model failure (custom endpoint, no catalog default)
|
||||
// must be tagged setup-fixable WITHOUT changing its message: the interactive
|
||||
// TUI matches the sentinel to drop into the wizard, while headless commands
|
||||
// print the exact actionable text.
|
||||
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "gw",
|
||||
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
||||
}`)
|
||||
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected requires-model error for a custom endpoint without model")
|
||||
}
|
||||
if !errors.Is(err, ErrProviderRequiresModel) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
|
||||
}
|
||||
// The sentinel must NOT prefix the user-facing message.
|
||||
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
|
||||
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "sk-x") {
|
||||
t.Fatalf("error leaked API key: %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -97,10 +97,6 @@ type ToolsConfig struct {
|
||||
|
||||
type PreferencesConfig struct {
|
||||
FavoriteModels []string `json:"favoriteModels,omitempty"`
|
||||
// Theme is the persisted TUI palette preference — "auto" or a registered theme
|
||||
// name (e.g. "dracula"). Applied at startup below the --theme flag and
|
||||
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
|
||||
// the user turned post-turn recaps off. A *bool is its own tri-state, so no
|
||||
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
|
||||
|
||||
@@ -66,13 +66,10 @@ func TestValidateFileRedactsSecretInIssue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateFileMissingModelWarns(t *testing.T) {
|
||||
// An openai-compatible CUSTOM endpoint has no catalog default to fall back
|
||||
// on, so a missing model is still a real issue — and the message must tell
|
||||
// the user how to fix it.
|
||||
path := writeValidateFixture(t, `{
|
||||
"activeProvider": "main",
|
||||
"providers": [
|
||||
{"name": "main", "provider_kind": "openai-compatible", "baseURL": "https://gateway.example/v1"}
|
||||
{"name": "main", "provider_kind": "anthropic"}
|
||||
]
|
||||
}`)
|
||||
|
||||
@@ -83,30 +80,6 @@ func TestValidateFileMissingModelWarns(t *testing.T) {
|
||||
if !strings.Contains(issues[0].Message, "requires model") {
|
||||
t.Fatalf("expected requires-model issue, got %#v", issues)
|
||||
}
|
||||
if !strings.Contains(issues[0].Message, "config.json") {
|
||||
t.Fatalf("requires-model issue should carry an actionable hint, got %#v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileDefaultsOfficialKindModels(t *testing.T) {
|
||||
// Official-API kinds (anthropic/google) fall back to their catalog default
|
||||
// model, so a hand-written model-less profile validates clean instead of
|
||||
// bricking zero config / bare zero setup — the only commands that could
|
||||
// have fixed it (the reported google case).
|
||||
path := writeValidateFixture(t, `{
|
||||
"activeProvider": "google",
|
||||
"providers": [
|
||||
{"name": "google", "provider_kind": "google", "apiKey": "AIza-x"},
|
||||
{"name": "anthropic", "provider_kind": "anthropic", "apiKey": "sk-ant-x"}
|
||||
]
|
||||
}`)
|
||||
|
||||
_, issues := ValidateFile(path)
|
||||
for _, issue := range issues {
|
||||
if strings.Contains(issue.Message, "requires model") {
|
||||
t.Fatalf("official-kind profiles must default their model, got issue %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFileValidConfigHasNoIssues(t *testing.T) {
|
||||
|
||||
@@ -154,28 +154,6 @@ func SetRecapsEnabled(path string, enabled bool) (FileConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetTheme persists the TUI theme preference, mirroring SetFavoriteModels
|
||||
// (read-modify-atomic-write). A blank theme clears the stored preference.
|
||||
func SetTheme(path string, theme string) (FileConfig, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return FileConfig{}, fmt.Errorf("config path is required")
|
||||
}
|
||||
cfg := FileConfig{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
cfg.Preferences.Theme = strings.TrimSpace(theme)
|
||||
if err := writeConfigFile(path, cfg); err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeFavoriteModels(models []string) []string {
|
||||
seen := map[string]bool{}
|
||||
favorites := make([]string, 0, len(models))
|
||||
|
||||
@@ -272,39 +272,6 @@ func TestSetFavoriteModelsPersistsUserPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetThemePersistsUserPreference(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
ActiveProvider: "openai",
|
||||
Providers: []ProviderProfile{
|
||||
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := SetTheme(path, " dracula ")
|
||||
if err != nil {
|
||||
t.Fatalf("SetTheme() error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.Theme != "dracula" {
|
||||
t.Fatalf("Theme = %q, want dracula (trimmed)", cfg.Preferences.Theme)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Preferences.Theme != "dracula" {
|
||||
t.Fatalf("persisted Theme = %q, want dracula", persisted.Preferences.Theme)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by SetTheme: %#v", persisted)
|
||||
}
|
||||
|
||||
// A blank value clears the stored preference.
|
||||
if cfg, err = SetTheme(path, ""); err != nil {
|
||||
t.Fatalf("SetTheme(\"\") error = %v", err)
|
||||
}
|
||||
if cfg.Preferences.Theme != "" {
|
||||
t.Fatalf("SetTheme(\"\") should clear the theme, got %q", cfg.Preferences.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecapsPreferenceRoundTrips(t *testing.T) {
|
||||
// Default (unset) is ON.
|
||||
if !(PreferencesConfig{}).RecapsEnabled() {
|
||||
|
||||
+21
-166
@@ -1,17 +1,16 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -212,85 +211,6 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/mcp" {
|
||||
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
|
||||
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
message := readHTTPRPCMessage(t, request)
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
|
||||
case "notifications/initialized":
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
|
||||
}
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
client, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: testServer.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("redirect target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: redirector.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -420,53 +340,6 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
|
||||
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
|
||||
if flusher, ok := response.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer stream.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeSSE,
|
||||
URL: stream.URL + "/sse",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -520,43 +393,28 @@ func TestConnectRejectsUnsupportedTransport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
|
||||
// Pipes, not a pre-filled bytes.Buffer: the fake server responds only AFTER
|
||||
// reading the outgoing request, matching production ordering. With responses
|
||||
// pre-loaded, the client's reader goroutine could consume the matching
|
||||
// response — and hit EOF — before request() registered its pending id,
|
||||
// flaking as "request() error = EOF" on fast runners.
|
||||
serverReader, clientWriter := io.Pipe() // client → server
|
||||
clientReader, serverWriter := io.Pipe() // server → client
|
||||
t.Cleanup(func() {
|
||||
_ = clientWriter.Close()
|
||||
_ = serverWriter.Close()
|
||||
})
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
requests := newMessageReader(serverReader)
|
||||
request, err := requests.read()
|
||||
if err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
responses := newMessageWriter(serverWriter)
|
||||
for _, message := range []rpcMessage{
|
||||
{Method: "notifications/progress"},
|
||||
{ID: 99, Error: &rpcError{Code: -32000, Message: "wrong response"}},
|
||||
{ID: request.ID, Result: mustRaw(map[string]any{"value": "matched"})},
|
||||
} {
|
||||
if err := responses.write(message); err != nil {
|
||||
serverErr <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
serverErr <- nil
|
||||
}()
|
||||
var incoming bytes.Buffer
|
||||
incomingWriter := newMessageWriter(&incoming)
|
||||
if err := incomingWriter.write(rpcMessage{Method: "notifications/progress"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := incomingWriter.write(rpcMessage{
|
||||
ID: 99,
|
||||
Error: &rpcError{Code: -32000, Message: "wrong response"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := incomingWriter.write(rpcMessage{
|
||||
ID: 1,
|
||||
Result: mustRaw(map[string]any{"value": "matched"}),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var outgoing bytes.Buffer
|
||||
client := &Client{
|
||||
reader: newMessageReader(clientReader),
|
||||
writer: newMessageWriter(clientWriter),
|
||||
reader: newMessageReader(&incoming),
|
||||
writer: newMessageWriter(&outgoing),
|
||||
nextID: 1,
|
||||
}
|
||||
var result struct {
|
||||
@@ -568,9 +426,6 @@ func TestClientRequestWaitsForMatchingResponseID(t *testing.T) {
|
||||
if result.Value != "matched" {
|
||||
t.Fatalf("result.Value = %q, want matched response", result.Value)
|
||||
}
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatalf("fake server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorCloser struct {
|
||||
|
||||
@@ -733,11 +733,7 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
||||
}
|
||||
resolvedURL := baseURL.ResolveReference(endpointURL)
|
||||
if !sameMCPOrigin(baseURL, resolvedURL) {
|
||||
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
|
||||
}
|
||||
return resolvedURL.String(), nil
|
||||
return baseURL.ResolveReference(endpointURL).String(), nil
|
||||
}
|
||||
|
||||
func rpcResponseKey(id any) string {
|
||||
@@ -877,10 +873,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
|
||||
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
|
||||
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
|
||||
// not declare OAuth use the default transport with the same MCP redirect guard.
|
||||
// not declare OAuth get the default client unchanged.
|
||||
func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
||||
return mcpHTTPClient(server, nil), nil
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{})
|
||||
if err != nil {
|
||||
@@ -892,5 +888,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
httpClient: http.DefaultClient,
|
||||
now: time.Now,
|
||||
}
|
||||
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
|
||||
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -150,141 +149,6 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
|
||||
source := &fakeTokenSource{}
|
||||
source.access.Store("access-secret")
|
||||
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("redirect target received Authorization = %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
client := mcpHTTPClient(
|
||||
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
|
||||
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
|
||||
)
|
||||
resp, err := client.Get(redirector.URL + "/mcp")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "https default port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://EXAMPLE.test:443/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "http default port",
|
||||
left: "http://example.test/mcp",
|
||||
right: "http://example.test:80/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different scheme",
|
||||
left: "https://example.test/mcp",
|
||||
right: "http://example.test:443/other",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://example.test:8443/other",
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
left, err := url.Parse(tc.left)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse(tc.right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sameMCPOrigin(left, right); got != tc.want {
|
||||
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
base string
|
||||
endpoint string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "relative endpoint",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "absolute same origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "default port equivalent",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test:443/messages",
|
||||
want: "https://example.test:443/messages",
|
||||
},
|
||||
{
|
||||
name: "cross origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://evil.test/messages",
|
||||
wantErr: "differs from configured server origin",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
||||
// Regression: a server without auth must not get a bearer header or any
|
||||
// OAuth round tripper, and must connect normally.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxMCPRedirects = 10
|
||||
|
||||
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: checkMCPRedirect(server),
|
||||
}
|
||||
}
|
||||
|
||||
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
|
||||
return func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxMCPRedirects {
|
||||
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
|
||||
}
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !sameMCPOrigin(via[0].URL, request.URL) {
|
||||
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectiveMCPPort(left) == effectiveMCPPort(right)
|
||||
}
|
||||
|
||||
func effectiveMCPPort(value *url.URL) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if port := value.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(value.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mcpOrigin(value *url.URL) string {
|
||||
if value == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
host := value.Hostname()
|
||||
port := effectiveMCPPort(value)
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(host, port)
|
||||
}
|
||||
if value.Scheme == "" {
|
||||
return host
|
||||
}
|
||||
return strings.ToLower(value.Scheme) + "://" + host
|
||||
}
|
||||
@@ -370,14 +370,7 @@ func TestFullFlowStoresTokens(t *testing.T) {
|
||||
redirect := parsed.Query().Get("redirect_uri")
|
||||
go func() {
|
||||
cbURL := redirect + "?code=auth-code&state=" + url.QueryEscape(state)
|
||||
for i := 0; i < 20; i++ {
|
||||
resp, err := http.Get(cbURL)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
_, _ = http.Get(cbURL)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,17 +44,10 @@ type Manager struct {
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
// AllowPresets opts this manager into the baked-in OAuth presets without the
|
||||
// operator exporting ZERO_OAUTH_ALLOW_PRESETS — used by the interactive wizard
|
||||
// and CLI login (and the runtime token refresh) for a provider the user chose
|
||||
// to sign into, whose preset client identity is public (e.g. xAI). It layers the
|
||||
// flag onto Env (or the process environment when Env is nil), preserving any
|
||||
// ZERO_OAUTH_<NAME>_* overrides. Leave false for hermetic tests.
|
||||
AllowPresets bool
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
@@ -90,13 +83,9 @@ func NewManager(opts ManagerOptions) (*Manager, error) {
|
||||
if open == nil {
|
||||
open = func(string) error { return nil }
|
||||
}
|
||||
env := opts.Env
|
||||
if opts.AllowPresets {
|
||||
env = envWithPresetsAllowed(env)
|
||||
}
|
||||
return &Manager{
|
||||
store: opts.Store, registry: registry, client: client,
|
||||
env: env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -52,30 +52,6 @@ func managerFor(t *testing.T, env map[string]string, openBrowser func(string) er
|
||||
return m
|
||||
}
|
||||
|
||||
// AllowPresets layers the opt-in onto the manager's env so a login/refresh for a
|
||||
// preset provider resolves; without it (and no Env) the env stays nil so callers
|
||||
// and tests remain hermetic.
|
||||
func TestNewManagerAllowPresetsForcesOptIn(t *testing.T) {
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{Store: store, AllowPresets: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if m.env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
|
||||
t.Fatalf("AllowPresets should force the opt-in; got env[%q]=%q", "ZERO_OAUTH_ALLOW_PRESETS", m.env["ZERO_OAUTH_ALLOW_PRESETS"])
|
||||
}
|
||||
m2, err := NewManager(ManagerOptions{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
if m2.env != nil {
|
||||
t.Fatalf("env should stay nil without AllowPresets, got %v", m2.env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoginLoopback(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// envWithPresetsAllowed returns an env map that opts into the baked-in presets
|
||||
// (as if ZERO_OAUTH_ALLOW_PRESETS=1 were exported) so an interactive wizard/CLI
|
||||
// login for a well-known public client (e.g. xAI) can use the preset without the
|
||||
// operator setting the flag themselves. It copies base — or snapshots the process
|
||||
// environment when base is nil — because envValue treats a non-nil map as hermetic
|
||||
// (a missing key does NOT fall back to os.Getenv), so a partial map would silently
|
||||
// drop the operator's ZERO_OAUTH_<NAME>_* overrides. The flag is then forced on.
|
||||
func envWithPresetsAllowed(base map[string]string) map[string]string {
|
||||
env := make(map[string]string, len(base)+1)
|
||||
if base == nil {
|
||||
for _, kv := range os.Environ() {
|
||||
if eq := strings.IndexByte(kv, '='); eq > 0 {
|
||||
env[kv[:eq]] = kv[eq+1:]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for k, v := range base {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
env["ZERO_OAUTH_ALLOW_PRESETS"] = "1"
|
||||
return env
|
||||
}
|
||||
import "strings"
|
||||
|
||||
// providerPreset is a baked-in default OAuth configuration for a well-known
|
||||
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
|
||||
|
||||
@@ -46,46 +46,6 @@ func TestResolveConfigUsesXAIPreset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// envWithPresetsAllowed lets a caller (the wizard / CLI login / runtime refresh)
|
||||
// opt into the preset without exporting ZERO_OAUTH_ALLOW_PRESETS: an otherwise-inert
|
||||
// xAI config now resolves from the baked-in preset.
|
||||
func TestEnvWithPresetsAllowedEnablesPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// Baseline: a hermetic empty map keeps the preset inert.
|
||||
if _, _, err := r.ResolveConfig("xai", map[string]string{}); err == nil {
|
||||
t.Fatal("xai should not resolve without the opt-in")
|
||||
}
|
||||
// The helper forces the opt-in so the preset resolves.
|
||||
cfg, _, err := r.ResolveConfig("xai", envWithPresetsAllowed(map[string]string{}))
|
||||
if err != nil {
|
||||
t.Fatalf("envWithPresetsAllowed should enable the xai preset: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
|
||||
t.Fatalf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper copies the base map (never mutating it) and keeps ZERO_OAUTH_<NAME>_*
|
||||
// overrides — critical because envValue treats a non-nil map as hermetic, so a
|
||||
// partial map would silently drop them.
|
||||
func TestEnvWithPresetsAllowedPreservesOverrides(t *testing.T) {
|
||||
base := map[string]string{"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id"}
|
||||
env := envWithPresetsAllowed(base)
|
||||
if env["ZERO_OAUTH_ALLOW_PRESETS"] != "1" {
|
||||
t.Fatalf("opt-in flag not set: %v", env)
|
||||
}
|
||||
if _, mutated := base["ZERO_OAUTH_ALLOW_PRESETS"]; mutated {
|
||||
t.Fatal("envWithPresetsAllowed mutated the caller's base map")
|
||||
}
|
||||
cfg, _, err := NewRegistry().ResolveConfig("xai", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig with override env: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "custom-id" {
|
||||
t.Fatalf("override dropped through the helper: client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
|
||||
@@ -41,26 +41,6 @@ func ValidateKey(key string) error {
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
||||
|
||||
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
|
||||
// that has a token in the store, with ok=false when none do. Callers pass
|
||||
// ProviderProfile.OAuthLoginCandidates() so that everything derived from a login
|
||||
// — the bearer token AND any header claim like chatgpt-account-id — comes from
|
||||
// the SAME login; selecting independently per consumer could otherwise pair a
|
||||
// bearer from one login with an account header from another. A load error on a
|
||||
// candidate is treated as a miss (skip to the next), never a hard failure.
|
||||
func FirstStored(store *Store, candidates []string) (Token, string, bool) {
|
||||
if store == nil {
|
||||
return Token{}, "", false
|
||||
}
|
||||
for _, name := range candidates {
|
||||
key := ProviderKey(name)
|
||||
if token, ok, err := store.Load(key); err == nil && ok {
|
||||
return token, key, true
|
||||
}
|
||||
}
|
||||
return Token{}, "", false
|
||||
}
|
||||
|
||||
// Status is a redaction-safe summary of a stored token (no secret material).
|
||||
type Status struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -123,10 +123,8 @@ var descriptors = []Descriptor{
|
||||
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
|
||||
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
|
||||
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
|
||||
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
|
||||
@@ -135,11 +133,7 @@ var descriptors = []Descriptor{
|
||||
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
|
||||
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
|
||||
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
|
||||
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
|
||||
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
|
||||
@@ -23,10 +23,8 @@ var expectedCatalogIDs = []string{
|
||||
"together",
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
"minimaxi-cn",
|
||||
"mistral",
|
||||
"github",
|
||||
"bedrock",
|
||||
@@ -36,10 +34,6 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"zai-cn",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
@@ -102,28 +96,6 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongCatDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("longcat")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(longcat) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "LongCat" {
|
||||
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "LongCat-2.0" {
|
||||
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -291,8 +263,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportGoogle: {"google"},
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -21,22 +21,6 @@ type Model struct {
|
||||
Source string
|
||||
}
|
||||
|
||||
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
|
||||
// per call, so the shared backing slice cannot be mutated by callers.
|
||||
var minimaxCuratedModels = []Model{
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
}
|
||||
|
||||
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
|
||||
// open.bigmodel.cn); same model lineup on both endpoints.
|
||||
var zaiCuratedModels = []Model{
|
||||
{ID: "glm-4.5", Description: "catalog default"},
|
||||
{ID: "glm-4.5-air", Description: "fast model"},
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
}
|
||||
|
||||
var curatedModels = map[string][]Model{
|
||||
"ollama-cloud": {
|
||||
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
||||
@@ -104,8 +88,10 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
|
||||
},
|
||||
"minimax": minimaxCuratedModels,
|
||||
"minimaxi-cn": minimaxCuratedModels,
|
||||
"minimax": {
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
},
|
||||
"mistral": {
|
||||
{ID: "mistral-large-latest", Description: "catalog default"},
|
||||
{ID: "codestral-latest", Description: "coding model"},
|
||||
@@ -137,8 +123,12 @@ var curatedModels = map[string][]Model{
|
||||
"bankr": {
|
||||
{ID: "bankr-large", Description: "catalog default"},
|
||||
},
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
"zai": {
|
||||
{ID: "glm-4.5", Description: "catalog default"},
|
||||
{ID: "glm-4.5-air", Description: "fast model"},
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
},
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||
|
||||
@@ -37,16 +37,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "minimaxi-cn",
|
||||
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "zai-cn",
|
||||
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -98,66 +88,3 @@ func modelIDs(models []Model) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
|
||||
// international provider; a divergence here is almost always a mistake.
|
||||
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
|
||||
intl, ok := providercatalog.Get("minimax")
|
||||
if !ok {
|
||||
t.Fatal("provider minimax missing from catalog")
|
||||
}
|
||||
cn, ok := providercatalog.Get("minimaxi-cn")
|
||||
if !ok {
|
||||
t.Fatal("provider minimaxi-cn missing from catalog")
|
||||
}
|
||||
intlModels := Models(intl)
|
||||
cnModels := Models(cn)
|
||||
if len(intlModels) != len(cnModels) {
|
||||
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||
}
|
||||
intlIDs := map[string]string{}
|
||||
for _, m := range intlModels {
|
||||
intlIDs[m.ID] = m.Description
|
||||
}
|
||||
for _, m := range cnModels {
|
||||
wantDesc, ok := intlIDs[m.ID]
|
||||
if !ok {
|
||||
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||
}
|
||||
if m.Description != wantDesc {
|
||||
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
|
||||
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
|
||||
// same model lineup.
|
||||
func TestZaiCNModelsMirrorInternational(t *testing.T) {
|
||||
intl, ok := providercatalog.Get("zai")
|
||||
if !ok {
|
||||
t.Fatal("provider zai missing from catalog")
|
||||
}
|
||||
cn, ok := providercatalog.Get("zai-cn")
|
||||
if !ok {
|
||||
t.Fatal("provider zai-cn missing from catalog")
|
||||
}
|
||||
intlModels := Models(intl)
|
||||
cnModels := Models(cn)
|
||||
if len(intlModels) != len(cnModels) {
|
||||
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||
}
|
||||
intlIDs := map[string]string{}
|
||||
for _, m := range intlModels {
|
||||
intlIDs[m.ID] = m.Description
|
||||
}
|
||||
for _, m := range cnModels {
|
||||
wantDesc, ok := intlIDs[m.ID]
|
||||
if !ok {
|
||||
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||
}
|
||||
if m.Description != wantDesc {
|
||||
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,10 +128,6 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
return "nvidia"
|
||||
case "xiaomi-mimo":
|
||||
return "xiaomi"
|
||||
case "zai-cn":
|
||||
return "zai"
|
||||
case "minimaxi-cn":
|
||||
return "minimax"
|
||||
default:
|
||||
return strings.TrimSpace(provider.ID)
|
||||
}
|
||||
|
||||
@@ -118,8 +118,6 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"dashscope": "alibaba",
|
||||
"ollama-cloud": "ollama-cloud",
|
||||
"zai-cn": "zai",
|
||||
"minimaxi-cn": "minimax",
|
||||
}
|
||||
for zeroID, want := range tests {
|
||||
provider, ok := providercatalog.Get(zeroID)
|
||||
|
||||
@@ -85,104 +85,6 @@ func Discover(ctx context.Context, profile config.ProviderProfile, options Optio
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverOllamaContextWindow asks a local Ollama daemon for a model's context
|
||||
// length via its native /api/show endpoint. The generic /v1/models probe
|
||||
// (parseModelsResponse) only ever returns id/description — OpenAI-compatible
|
||||
// listings don't carry context-window metadata, and a custom/local model tag
|
||||
// (e.g. a user's own Ollama pull, including a ":cloud"-tagged model proxied
|
||||
// through the local daemon) has no curated-catalog entry to borrow one from
|
||||
// either, so modelContextWindow has nothing to show for it without this. Only
|
||||
// meaningful for the local Ollama provider (baseURL like
|
||||
// http://localhost:11434/v1) — Ollama Cloud's hosted API is a different
|
||||
// service and isn't assumed to expose the same endpoint.
|
||||
func DiscoverOllamaContextWindow(ctx context.Context, baseURL string, model string, options Options) (int, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return 0, fmt.Errorf("model name is required")
|
||||
}
|
||||
endpoint, err := ollamaShowEndpoint(baseURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Model string `json:"model"`
|
||||
}{Model: model})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("ollama show endpoint returned %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return parseOllamaShowContextWindow(body)
|
||||
}
|
||||
|
||||
// ollamaShowEndpoint derives the native Ollama API root from the
|
||||
// OpenAI-compatible base URL Zero stores for the provider (".../v1") and
|
||||
// appends /api/show.
|
||||
func ollamaShowEndpoint(baseURL string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return "", fmt.Errorf("provider base URL is required for ollama model discovery")
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid provider base URL %q", baseURL)
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
path = strings.TrimSuffix(path, "/v1")
|
||||
parsed.Path = strings.TrimRight(path, "/") + "/api/show"
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// parseOllamaShowContextWindow extracts the context length from an
|
||||
// /api/show response. Ollama reports it under model_info with an
|
||||
// architecture-prefixed key (e.g. "llama.context_length",
|
||||
// "qwen2.context_length") — the prefix varies by model family, so this scans
|
||||
// for any key ending in ".context_length" rather than hardcoding one.
|
||||
func parseOllamaShowContextWindow(body []byte) (int, error) {
|
||||
// model_info mixes value types (strings like "general.architecture", numbers
|
||||
// like "*.context_length"), so decode values generically and only try to
|
||||
// read a number out of the one key we actually care about.
|
||||
var payload struct {
|
||||
ModelInfo map[string]any `json:"model_info"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, fmt.Errorf("decode ollama show response: %w", err)
|
||||
}
|
||||
for key, value := range payload.ModelInfo {
|
||||
if !strings.HasSuffix(key, ".context_length") {
|
||||
continue
|
||||
}
|
||||
if window, ok := value.(float64); ok && window > 0 {
|
||||
return int(window), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("ollama show response did not report a context length")
|
||||
}
|
||||
|
||||
func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, options Options) ([]Model, error) {
|
||||
endpoint, err := modelsEndpoint(profile.BaseURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package providermodeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -268,61 +267,3 @@ func modelIDs(models []Model) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint: the generic
|
||||
// /v1/models probe never carries context-window metadata (parseModelsResponse
|
||||
// only extracts id/description), so a custom/local Ollama model tag with no
|
||||
// curated-catalog match has no other source for it. This exercises the
|
||||
// Ollama-native /api/show fallback that fills that gap.
|
||||
func TestDiscoverOllamaContextWindowFetchesFromNativeShowEndpoint(t *testing.T) {
|
||||
var gotPath, gotMethod, gotBody string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"model_info": {
|
||||
"general.architecture": "qwen2",
|
||||
"qwen2.context_length": 131072
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
window, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "kimi-k2.7-code:cloud", Options{HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverOllamaContextWindow returned error: %v", err)
|
||||
}
|
||||
if window != 131072 {
|
||||
t.Fatalf("context window = %d, want 131072", window)
|
||||
}
|
||||
if gotPath != "/api/show" {
|
||||
t.Fatalf("requested path = %q, want /api/show (not under /v1)", gotPath)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"kimi-k2.7-code:cloud"`) {
|
||||
t.Fatalf("request body = %q, want it to name the model", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowRequiresModelName(t *testing.T) {
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), "http://localhost:11434/v1", "", Options{}); err == nil {
|
||||
t.Fatal("expected an error for an empty model name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverOllamaContextWindowErrorsWhenShowOmitsContextLength(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"model_info": {"general.architecture": "qwen2"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := DiscoverOllamaContextWindow(context.Background(), server.URL+"/v1", "some-model", Options{HTTPClient: server.Client()}); err == nil {
|
||||
t.Fatal("expected an error when no *.context_length key is present")
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,6 @@ type Options struct {
|
||||
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
||||
// Applied to the OpenAI and Anthropic providers.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
|
||||
// there is no OAuth login). It is the SAME selection the bearer resolver made,
|
||||
// resolved ONCE by the caller so the Codex chatgpt-account-id header reads its
|
||||
// account from the exact login that issued the bearer token — never a second,
|
||||
// independent lookup that could pick a different login (a backend-rejected
|
||||
// mismatch).
|
||||
OAuthLoginKey string
|
||||
}
|
||||
|
||||
// New creates a runtime provider for a resolved provider profile.
|
||||
@@ -258,22 +251,14 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
|
||||
|
||||
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
|
||||
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
|
||||
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
|
||||
// the stored OAuth token's Account field on every request so a refresh that
|
||||
// rotates the token (and its account claim) takes effect immediately — a static
|
||||
// AccountID captured at construction would go stale on the first refresh.
|
||||
//
|
||||
// The *login* the header reads from is options.OAuthLoginKey — the SAME key the
|
||||
// bearer resolver bound, resolved ONCE by the caller. The bearer resolver
|
||||
// refreshes the token in place under that key; reading the account from the same
|
||||
// key keeps header and token on one login for the provider's life. Resolving the
|
||||
// key independently here (a second oauth.FirstStored) could, after a transient
|
||||
// per-candidate load error or a mid-session `zero auth login`, pick a different
|
||||
// login than the bearer — a mismatch the backend rejects.
|
||||
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is
|
||||
// resolved dynamically from the stored OAuth token's Account field on
|
||||
// every request so a refresh that rotates the token (and its account
|
||||
// claim) takes effect immediately — a static AccountID captured at
|
||||
// construction would go stale on the first refresh.
|
||||
func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile, options Options) (zeroruntime.Provider, error) {
|
||||
accountKey := options.OAuthLoginKey
|
||||
resolver := openai.CodexAccountResolver(func(ctx context.Context) (string, bool, error) {
|
||||
account := codexAccountForKey(accountKey)
|
||||
account := codexAccountFromStore(profile.Name)
|
||||
if account == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -303,20 +288,17 @@ func newCodexProvider(profile config.ProviderProfile, resolved resolvedProfile,
|
||||
})
|
||||
}
|
||||
|
||||
// codexAccountForKey reads the chatgpt_account_id from the token currently
|
||||
// stored under key. Called per-request (via the resolver) so a refresh that
|
||||
// rotates the token — saved back in place under the same key — takes effect
|
||||
// immediately without restarting the agent. Returns "" when key is empty (no
|
||||
// OAuth login), or the token is absent or carries no account claim.
|
||||
func codexAccountForKey(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
// codexAccountFromStore reads the chatgpt_account_id from the stored OAuth
|
||||
// token for the given provider name. It is called per-request (via the
|
||||
// resolver) so a refresh that rotates the token takes effect immediately
|
||||
// without restarting the agent. Returns "" when no token is stored (the
|
||||
// Codex provider then omits the header and the user sees a clear 401).
|
||||
func codexAccountFromStore(providerName string) string {
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
token, ok, err := store.Load(key)
|
||||
token, ok, err := store.Load(oauth.ProviderKey(providerName))
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -269,7 +269,6 @@ 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{
|
||||
@@ -323,10 +322,11 @@ func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
|
||||
|
||||
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
|
||||
// The factory reads the stored OAuth token's Account field for the
|
||||
// chatgpt-account-id header, from the login key the CALLER supplies in
|
||||
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
|
||||
// token in an isolated temp store, then pass that key.
|
||||
store, err := newOAuthStoreForTest(t)
|
||||
// chatgpt-account-id header. Seed a token in a temp store and point
|
||||
// ZERO_OAUTH_TOKENS_PATH at it so the factory picks it up.
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", dir+"/tokens.json")
|
||||
store, err := newOAuthStoreForTest()
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
@@ -344,9 +344,8 @@ func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
|
||||
CatalogID: "chatgpt",
|
||||
Model: "gpt-5",
|
||||
}, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-factory-test",
|
||||
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-factory-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
@@ -408,61 +407,10 @@ func (transport *captureTransport) body() io.Reader {
|
||||
return strings.NewReader(transport.requestBody)
|
||||
}
|
||||
|
||||
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
|
||||
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
|
||||
// path: an inherited "keyring" value would send NewStore to the OS keychain
|
||||
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
|
||||
// developer's real logins. Exists so the chatgpt factory tests can seed a
|
||||
// token without copying the path-handling dance from internal/cli.
|
||||
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
|
||||
t.Helper()
|
||||
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
|
||||
// newOAuthStoreForTest builds a Store pointed at the current
|
||||
// ZERO_OAUTH_TOKENS_PATH (set by the caller). It exists so the chatgpt
|
||||
// factory tests can seed a token without copying the path-handling dance
|
||||
// from internal/cli.
|
||||
func newOAuthStoreForTest() (*oauth.Store, error) {
|
||||
return oauth.NewStore(oauth.StoreOptions{})
|
||||
}
|
||||
|
||||
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
|
||||
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
|
||||
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
|
||||
// therefore always read from the same login that issued the bearer (no second,
|
||||
// independent selection). It re-reads per call, so an in-place token refresh (new
|
||||
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
|
||||
// or a missing/account-less token yields "".
|
||||
func TestCodexAccountForKey(t *testing.T) {
|
||||
store, err := newOAuthStoreForTest(t)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
|
||||
t.Fatalf("Save chatgpt: %v", err)
|
||||
}
|
||||
// A different login WITHOUT an account claim, to prove the read stays on the
|
||||
// requested key and never crosses to another login's account.
|
||||
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
|
||||
t.Fatalf("Save codex: %v", err)
|
||||
}
|
||||
|
||||
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
|
||||
t.Fatalf("account = %q, want acc-catalog-7", got)
|
||||
}
|
||||
// The bound key's token has no account → "", NOT the other login's account.
|
||||
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
|
||||
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
|
||||
}
|
||||
// Empty key (no OAuth login) → header omitted.
|
||||
if got := codexAccountForKey(""); got != "" {
|
||||
t.Fatalf("account for empty key = %q, want empty", got)
|
||||
}
|
||||
// Unknown key → "".
|
||||
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
|
||||
t.Fatalf("account for unknown key = %q, want empty", got)
|
||||
}
|
||||
|
||||
// An in-place refresh under the bound key IS reflected per request.
|
||||
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
|
||||
t.Fatalf("refresh chatgpt: %v", err)
|
||||
}
|
||||
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
|
||||
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
|
||||
}
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
|
||||
declarations = append(declarations, geminiFunctionDeclaration{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
Parameters: sanitizeGeminiSchema(tool.Parameters),
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
}
|
||||
mapped.Tools = []geminiToolGroup{{FunctionDeclarations: declarations}}
|
||||
@@ -350,71 +350,6 @@ func (provider *Provider) geminiRequest(request zeroruntime.CompletionRequest) (
|
||||
return mapped, nil
|
||||
}
|
||||
|
||||
// geminiSchemaFields is the subset of JSON Schema keywords Google's
|
||||
// functionDeclarations[].parameters accepts (the Generative AI Schema type).
|
||||
// Anything outside it — most notably OpenAI's `additionalProperties`, which Zero
|
||||
// emits on every tool's parameters and which Gemini 400s on ("Unknown name
|
||||
// \"additionalProperties\" … Cannot find field") — must be dropped before the
|
||||
// request goes out. Kept as an allowlist rather than a denylist so a schema
|
||||
// keyword Zero (or an MCP server) adds later can't silently leak an unsupported
|
||||
// field into the Gemini payload.
|
||||
var geminiSchemaFields = map[string]bool{
|
||||
"type": true, "format": true, "title": true, "description": true,
|
||||
"nullable": true, "enum": true, "items": true, "properties": true,
|
||||
"required": true, "anyOf": true, "propertyOrdering": true, "default": true,
|
||||
"minimum": true, "maximum": true, "minItems": true, "maxItems": true,
|
||||
"minLength": true, "maxLength": true, "minProperties": true,
|
||||
"maxProperties": true, "pattern": true, "example": true,
|
||||
}
|
||||
|
||||
// sanitizeGeminiSchema returns a copy of a JSON-Schema map keeping only the
|
||||
// keywords Gemini supports (geminiSchemaFields), recursing through the nested
|
||||
// schemas under `properties`, `items`, and `anyOf`. Returns nil for a nil input
|
||||
// so a parameterless tool stays parameterless.
|
||||
func sanitizeGeminiSchema(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
if !geminiSchemaFields[key] {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "properties":
|
||||
if props, ok := value.(map[string]any); ok {
|
||||
cleaned := make(map[string]any, len(props))
|
||||
for name, sub := range props {
|
||||
if subMap, ok := sub.(map[string]any); ok {
|
||||
cleaned[name] = sanitizeGeminiSchema(subMap)
|
||||
} else {
|
||||
cleaned[name] = sub
|
||||
}
|
||||
}
|
||||
value = cleaned
|
||||
}
|
||||
case "items":
|
||||
if subMap, ok := value.(map[string]any); ok {
|
||||
value = sanitizeGeminiSchema(subMap)
|
||||
}
|
||||
case "anyOf":
|
||||
if variants, ok := value.([]any); ok {
|
||||
cleaned := make([]any, len(variants))
|
||||
for i, variant := range variants {
|
||||
if subMap, ok := variant.(map[string]any); ok {
|
||||
cleaned[i] = sanitizeGeminiSchema(subMap)
|
||||
} else {
|
||||
cleaned[i] = variant
|
||||
}
|
||||
}
|
||||
value = cleaned
|
||||
}
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapMessages(messages []zeroruntime.Message) (*geminiContent, []geminiContent, error) {
|
||||
systemParts := []geminiPart{}
|
||||
contents := []geminiContent{}
|
||||
|
||||
@@ -3,7 +3,6 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -505,129 +504,3 @@ func eventsOfType(events []zeroruntime.StreamEvent, eventType zeroruntime.Stream
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
// TestSanitizeGeminiSchemaStripsUnsupportedFields: the sanitizer keeps only
|
||||
// Gemini-supported keywords and recurses into properties/items/anyOf, so no
|
||||
// OpenAI-ism (additionalProperties, $schema, patternProperties) survives at any
|
||||
// depth while legitimate fields (type/description/enum/required/default) stay.
|
||||
func TestSanitizeGeminiSchemaStripsUnsupportedFields(t *testing.T) {
|
||||
in := map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"required": []string{"path"},
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "a path",
|
||||
"default": ".",
|
||||
},
|
||||
"nested": map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": false, // must be stripped at depth too
|
||||
"properties": map[string]any{
|
||||
"tags": map[string]any{
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string", "additionalProperties": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := sanitizeGeminiSchema(in)
|
||||
|
||||
assertNoAdditionalProps(t, out, "root")
|
||||
if out["additionalProperties"] != nil {
|
||||
t.Fatal("top-level additionalProperties must be stripped")
|
||||
}
|
||||
if out["$schema"] != nil {
|
||||
t.Fatal("$schema must be stripped")
|
||||
}
|
||||
// Legitimate fields survive.
|
||||
if out["type"] != "object" {
|
||||
t.Fatalf("type should survive, got %v", out["type"])
|
||||
}
|
||||
props := out["properties"].(map[string]any)
|
||||
pathProp := props["path"].(map[string]any)
|
||||
if pathProp["description"] != "a path" || pathProp["default"] != "." {
|
||||
t.Fatalf("legitimate property fields must survive: %#v", pathProp)
|
||||
}
|
||||
// nil input stays nil (parameterless tool).
|
||||
if sanitizeGeminiSchema(nil) != nil {
|
||||
t.Fatal("nil schema should map to nil")
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoAdditionalProps fails if additionalProperties appears anywhere in the
|
||||
// (recursively walked) schema.
|
||||
func assertNoAdditionalProps(t *testing.T, node any, path string) {
|
||||
t.Helper()
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if _, ok := v["additionalProperties"]; ok {
|
||||
t.Fatalf("additionalProperties leaked at %s", path)
|
||||
}
|
||||
for k, sub := range v {
|
||||
assertNoAdditionalProps(t, sub, path+"."+k)
|
||||
}
|
||||
case []any:
|
||||
for i, sub := range v {
|
||||
assertNoAdditionalProps(t, sub, fmt.Sprintf("%s[%d]", path, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiRequestOmitsAdditionalPropertiesInToolSchema: end-to-end, the tool
|
||||
// parameters Zero emits (schemaToRuntimeMap always writes additionalProperties)
|
||||
// must not reach Gemini — otherwise every functionDeclaration is 400-rejected
|
||||
// ("Unknown name additionalProperties"), which broke all tool-using exec calls
|
||||
// against Google (issue #373).
|
||||
func TestGeminiRequestOmitsAdditionalPropertiesInToolSchema(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
writeSSE(w, `{"usageMetadata":{"promptTokenCount":1}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider, err := New(Options{APIKey: "k", BaseURL: server.URL + "/", Model: "models/gemini-2.5-flash"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
Tools: []zeroruntime.ToolDefinition{{
|
||||
Name: "grep",
|
||||
Description: "search",
|
||||
Parameters: map[string]any{ // exactly what schemaToRuntimeMap produces
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": []string{"pattern"},
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "regex"},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion returned error: %v", err)
|
||||
}
|
||||
drain(stream)
|
||||
|
||||
tools := gotBody["tools"].([]any)
|
||||
decls := tools[0].(map[string]any)["functionDeclarations"].([]any)
|
||||
params := decls[0].(map[string]any)["parameters"].(map[string]any)
|
||||
if _, ok := params["additionalProperties"]; ok {
|
||||
t.Fatalf("additionalProperties must not be sent to Gemini: %#v", params)
|
||||
}
|
||||
// The tool is still usable: its real schema survived.
|
||||
if params["type"] != "object" {
|
||||
t.Fatalf("tool parameters type should survive, got %v", params["type"])
|
||||
}
|
||||
props := params["properties"].(map[string]any)
|
||||
if _, ok := props["pattern"]; !ok {
|
||||
t.Fatalf("tool property must survive sanitization: %#v", props)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,9 +25,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 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.
|
||||
// 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.
|
||||
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
|
||||
@@ -40,7 +38,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)", ContentStallTimeout(idleTimeout))
|
||||
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("idle timeout after %s (upstream stopped sending data)", idleTimeout)
|
||||
}
|
||||
@@ -55,32 +53,13 @@ func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
|
||||
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
|
||||
const DefaultStreamIdleTimeout = 5 * time.Minute
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
|
||||
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
|
||||
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
|
||||
@@ -143,21 +122,6 @@ 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
|
||||
@@ -168,7 +132,6 @@ 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}
|
||||
}()
|
||||
|
||||
@@ -322,7 +285,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 := ContentStallTimeout(idleTimeout)
|
||||
contentTimeout := idleTimeout * streamContentStallFactor
|
||||
content := time.NewTimer(contentTimeout)
|
||||
defer content.Stop()
|
||||
contentC = content.C
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -205,36 +203,11 @@ 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
|
||||
// (ContentStallTimeout(idle)) aborts with ErrStreamStalled when no real data
|
||||
// line arrives. This is the gpt-5.x / ollama "still generating forever" hang.
|
||||
// (idleTimeout × streamContentStallFactor) 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() }()
|
||||
@@ -262,8 +235,7 @@ func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
// idle 100ms → content stall at 120ms (ContentStallTimeout = idle*1.2).
|
||||
// Keep-alives reset idle but not content.
|
||||
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
|
||||
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
|
||||
}()
|
||||
|
||||
@@ -286,7 +258,7 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
// One data line every 30ms for ~300ms (well past the 120ms content window,
|
||||
// One data line every 30ms for ~300ms (well past the 200ms 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++ {
|
||||
@@ -318,16 +290,16 @@ func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
|
||||
}
|
||||
|
||||
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
|
||||
// it reports the content window (ContentStallTimeout) and must NOT claim the
|
||||
// upstream "stopped sending data" (keep-alives were still arriving).
|
||||
// it reports the content window (idle × factor) 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 6m") {
|
||||
t.Fatalf("stalled message = %q, want it to report the 6m content window (idle 5m × 1.2)", stalled)
|
||||
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, "stopped sending data") {
|
||||
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
|
||||
@@ -352,17 +324,6 @@ 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,25 +9,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy verifies that the
|
||||
// sandbox network policy gates sandboxed shell egress, not first-party
|
||||
// in-process web tools.
|
||||
func TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy(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: PermissionPrompt,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow,
|
||||
})
|
||||
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)
|
||||
if d.Action != ActionAllow || d.Block != nil {
|
||||
t.Fatalf("web_search must be allowed by the sandbox gate, got %#v", d)
|
||||
}
|
||||
|
||||
allowed := engine.Evaluate(context.Background(), Request{
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
|
||||
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionAllow, PermissionGranted: true,
|
||||
})
|
||||
if allowed.Action != ActionAllow || allowed.Block != nil {
|
||||
t.Fatalf("granted web_search must be allowed under deny, got %#v", allowed)
|
||||
|
||||
@@ -19,13 +19,6 @@ 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
|
||||
@@ -181,28 +174,19 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
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 := ""
|
||||
@@ -212,11 +196,7 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
downgradeReason = windowsSetupDowngradeReason
|
||||
}
|
||||
}
|
||||
if enforcementLevel == EnforcementUnelevated {
|
||||
downgradeReason = windowsUnelevatedDowngradeReason
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available &&
|
||||
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
|
||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
||||
markers := backend.SandboxEnvMarkers(policy)
|
||||
if !wrapped && backend.Name != BackendWSL {
|
||||
markers = nil
|
||||
|
||||
@@ -72,7 +72,7 @@ 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 runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
if pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,64 +132,6 @@ 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,7 +3,6 @@ package sandbox
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -129,7 +128,7 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
if pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,16 +143,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
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"
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
|
||||
@@ -8,21 +8,14 @@ import (
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
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:
|
||||
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
|
||||
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
|
||||
@@ -64,32 +57,3 @@ 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,16 +2,14 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
|
||||
@@ -26,70 +24,13 @@ func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
|
||||
ValidateExecution: true,
|
||||
}
|
||||
|
||||
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
|
||||
// documenting the missing network enforcement, no error.
|
||||
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
auto := base
|
||||
auto.Preference = SandboxPreferenceAuto
|
||||
req, err := mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
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)
|
||||
t.Fatalf("auto must degrade, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
|
||||
@@ -100,4 +41,21 @@ func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(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,14 +95,8 @@ type WindowsSandboxLevel string
|
||||
|
||||
const (
|
||||
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
|
||||
// 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"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
)
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
@@ -277,20 +271,13 @@ 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: level,
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -394,7 +381,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
|
||||
|
||||
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
|
||||
switch level {
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const windowsUnelevatedSetupMarkerSchemaVersion = 1
|
||||
|
||||
// windowsUnelevatedSetupMarkerMaxPlans bounds the applied-plan history so the
|
||||
// marker cannot grow without limit when a user hops between many workspaces.
|
||||
// Oldest entries are dropped first; re-applying an already-granted plan is
|
||||
// harmless, so eviction only costs a redundant ACL apply.
|
||||
const windowsUnelevatedSetupMarkerMaxPlans = 64
|
||||
|
||||
// WindowsUnelevatedAppliedPlan fingerprints one workspace ACL plan the
|
||||
// unelevated tier has applied. Unlike the elevated setup marker it carries no
|
||||
// network infrastructure hash: the unelevated tier never provisions WFP
|
||||
// filters, so there is nothing network-side to fingerprint.
|
||||
type WindowsUnelevatedAppliedPlan struct {
|
||||
ACLPlanHash string `json:"aclPlanHash"`
|
||||
ACLPlanEntries int `json:"aclPlanEntries"`
|
||||
}
|
||||
|
||||
// WindowsUnelevatedSetupMarker records which workspace ACL plans the command
|
||||
// runner has applied without elevation, keyed by plan hash, so repeat commands
|
||||
// in a known workspace skip the re-apply. One file serves every workspace under
|
||||
// a sandbox home (the elevated marker is instead a single-plan snapshot,
|
||||
// because its WFP half genuinely is machine-global).
|
||||
type WindowsUnelevatedSetupMarker struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
AppliedPlans []WindowsUnelevatedAppliedPlan `json:"appliedPlans,omitempty"`
|
||||
}
|
||||
|
||||
func WindowsUnelevatedSetupMarkerPath(sandboxHome string) string {
|
||||
return filepath.Join(filepath.Clean(sandboxHome), "windows-unelevated-setup.json")
|
||||
}
|
||||
|
||||
// buildWindowsUnelevatedAppliedPlan derives the ACL plan for the command's
|
||||
// permission profile plus its marker fingerprint. The same BuildWindowsACLPlan
|
||||
// output later feeds the apply step, so the fingerprint and the applied grants
|
||||
// can never drift apart.
|
||||
func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (WindowsUnelevatedAppliedPlan, WindowsACLPlan, error) {
|
||||
plan, err := BuildWindowsACLPlan(config)
|
||||
if err != nil {
|
||||
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
|
||||
}
|
||||
hash, err := WindowsACLPlanHash(plan)
|
||||
if err != nil {
|
||||
return WindowsUnelevatedAppliedPlan{}, WindowsACLPlan{}, err
|
||||
}
|
||||
return WindowsUnelevatedAppliedPlan{
|
||||
ACLPlanHash: hash,
|
||||
ACLPlanEntries: len(plan.Entries),
|
||||
}, plan, nil
|
||||
}
|
||||
|
||||
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
|
||||
// common first-run state and returns an empty marker, not an error. A marker
|
||||
// with an unexpected schema version is treated as empty so the runner re-applies
|
||||
// (and rewrites) rather than failing.
|
||||
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
|
||||
sandboxHome = strings.TrimSpace(sandboxHome)
|
||||
if sandboxHome == "" {
|
||||
return WindowsUnelevatedSetupMarker{}, errors.New("windows sandbox home is required")
|
||||
}
|
||||
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
|
||||
bytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
}
|
||||
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("read windows unelevated setup marker: %w", err)
|
||||
}
|
||||
var marker WindowsUnelevatedSetupMarker
|
||||
if err := json.Unmarshal(bytes, &marker); err != nil {
|
||||
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
|
||||
}
|
||||
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
}
|
||||
return marker, nil
|
||||
}
|
||||
|
||||
func (marker WindowsUnelevatedSetupMarker) contains(applied WindowsUnelevatedAppliedPlan) bool {
|
||||
if strings.TrimSpace(applied.ACLPlanHash) == "" {
|
||||
return false
|
||||
}
|
||||
for _, plan := range marker.AppliedPlans {
|
||||
if plan.ACLPlanHash == applied.ACLPlanHash && plan.ACLPlanEntries == applied.ACLPlanEntries {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recordWindowsUnelevatedAppliedPlan appends the plan to the marker (dropping
|
||||
// the oldest entries past the cap) and writes it atomically, following the
|
||||
// temp-file-then-rename pattern of the sibling marker writers.
|
||||
func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnelevatedAppliedPlan) error {
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(sandboxHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
marker.SchemaVersion = windowsUnelevatedSetupMarkerSchemaVersion
|
||||
marker.AppliedPlans = append(marker.AppliedPlans, applied)
|
||||
if overflow := len(marker.AppliedPlans) - windowsUnelevatedSetupMarkerMaxPlans; overflow > 0 {
|
||||
marker.AppliedPlans = append([]WindowsUnelevatedAppliedPlan(nil), marker.AppliedPlans[overflow:]...)
|
||||
}
|
||||
path := WindowsUnelevatedSetupMarkerPath(sandboxHome)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create windows unelevated setup marker dir: %w", err)
|
||||
}
|
||||
bytes, err := json.MarshalIndent(marker, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal windows unelevated setup marker: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".windows-unelevated-setup-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create windows unelevated setup marker temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(bytes); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("write windows unelevated setup marker temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("close windows unelevated setup marker temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("replace windows unelevated setup marker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testWindowsUnelevatedCommandConfig(t *testing.T) WindowsSandboxCommandConfig {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
return WindowsSandboxCommandConfig{
|
||||
SandboxHome: filepath.Join(root, ".zero-sandbox"),
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
Command: []string{"cmd.exe", "/c", "echo hi"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsSandboxCommandArgsRoundTripUnelevatedLevel(t *testing.T) {
|
||||
config := testWindowsUnelevatedCommandConfig(t)
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: config.SandboxHome,
|
||||
CommandCWD: config.CommandCWD,
|
||||
WorkspaceRoots: config.WorkspaceRoots,
|
||||
PermissionProfile: config.PermissionProfile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
Command: config.Command,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWindowsSandboxCommandArgs: %v", err)
|
||||
}
|
||||
parsed, err := ParseWindowsSandboxCommandArgs(args)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseWindowsSandboxCommandArgs: %v", err)
|
||||
}
|
||||
if parsed.SandboxLevel != WindowsSandboxLevelUnelevated {
|
||||
t.Fatalf("round-tripped sandbox level = %q, want %q", parsed.SandboxLevel, WindowsSandboxLevelUnelevated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWindowsUnelevatedAppliedPlanFingerprint(t *testing.T) {
|
||||
config := testWindowsUnelevatedCommandConfig(t)
|
||||
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
|
||||
}
|
||||
if applied.ACLPlanHash == "" || applied.ACLPlanEntries == 0 || applied.ACLPlanEntries != len(plan.Entries) {
|
||||
t.Fatalf("applied plan = %+v with %d plan entries, want a non-empty consistent fingerprint", applied, len(plan.Entries))
|
||||
}
|
||||
// Same config -> same fingerprint (the marker's dedupe key).
|
||||
again, _, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild: %v", err)
|
||||
}
|
||||
if again != applied {
|
||||
t.Fatalf("fingerprint not stable: %+v then %+v", applied, again)
|
||||
}
|
||||
// An unrestricted profile has no ACL plan to apply and must error.
|
||||
config.PermissionProfile.FileSystem.Kind = FileSystemUnrestricted
|
||||
if _, _, err := buildWindowsUnelevatedAppliedPlan(config); err == nil {
|
||||
t.Fatal("expected an error for an unrestricted filesystem profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsUnelevatedSetupMarkerRecordAndLoad(t *testing.T) {
|
||||
config := testWindowsUnelevatedCommandConfig(t)
|
||||
applied, _, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildWindowsUnelevatedAppliedPlan: %v", err)
|
||||
}
|
||||
|
||||
// Missing marker file is the fresh-install state: empty, no error, no match.
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
|
||||
if err != nil {
|
||||
t.Fatalf("load missing marker: %v", err)
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
t.Fatal("empty marker must not contain the plan")
|
||||
}
|
||||
|
||||
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
|
||||
if err != nil {
|
||||
t.Fatalf("load recorded marker: %v", err)
|
||||
}
|
||||
if !marker.contains(applied) || len(marker.AppliedPlans) != 1 {
|
||||
t.Fatalf("marker = %+v, want exactly the recorded plan", marker)
|
||||
}
|
||||
|
||||
// Recording the same plan again must not duplicate it.
|
||||
if err := recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied); err != nil {
|
||||
t.Fatalf("re-record: %v", err)
|
||||
}
|
||||
marker, err = loadWindowsUnelevatedSetupMarker(config.SandboxHome)
|
||||
if err != nil {
|
||||
t.Fatalf("reload marker: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != 1 {
|
||||
t.Fatalf("marker has %d plans after duplicate record, want 1", len(marker.AppliedPlans))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".zero-sandbox")
|
||||
first := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-0", ACLPlanEntries: 1}
|
||||
if err := recordWindowsUnelevatedAppliedPlan(home, first); err != nil {
|
||||
t.Fatalf("record first: %v", err)
|
||||
}
|
||||
for index := 1; index <= windowsUnelevatedSetupMarkerMaxPlans; index++ {
|
||||
plan := WindowsUnelevatedAppliedPlan{ACLPlanHash: fmt.Sprintf("hash-%d", index), ACLPlanEntries: index}
|
||||
if err := recordWindowsUnelevatedAppliedPlan(home, plan); err != nil {
|
||||
t.Fatalf("record %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != windowsUnelevatedSetupMarkerMaxPlans {
|
||||
t.Fatalf("marker has %d plans, want the cap of %d", len(marker.AppliedPlans), windowsUnelevatedSetupMarkerMaxPlans)
|
||||
}
|
||||
if marker.contains(first) {
|
||||
t.Fatal("oldest plan should have been evicted past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), ".zero-sandbox")
|
||||
if err := os.MkdirAll(home, 0o700); err != nil {
|
||||
t.Fatalf("mkdir sandbox home: %v", err)
|
||||
}
|
||||
path := WindowsUnelevatedSetupMarkerPath(home)
|
||||
if err := os.WriteFile(path, []byte(`{"schemaVersion":99,"appliedPlans":[{"aclPlanHash":"h","aclPlanEntries":1}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write marker: %v", err)
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
t.Fatalf("unknown schema must reset to empty current-schema marker, got %+v", marker)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
@@ -121,7 +120,7 @@ var knownToolNames = map[string]bool{
|
||||
}
|
||||
|
||||
func DefaultPaths(workspaceRoot string) (Paths, error) {
|
||||
userConfigDir, err := config.UserConfigDir()
|
||||
userConfigDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return Paths{}, fmt.Errorf("resolve user config directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -124,16 +124,10 @@ func TestOutputToolBlocksUntilTaskCompletes(t *testing.T) {
|
||||
tool := NewOutputTool(manager)
|
||||
tool.PollInterval = time.Millisecond
|
||||
|
||||
// timeout is a ceiling, not a target: the test still returns as soon as the
|
||||
// 5ms write lands (PollInterval keeps it responsive), it just tolerates a
|
||||
// slow/contended CI runner without failing. A 100ms budget against a 5ms
|
||||
// write only left 20x margin, which Windows CI runners (coarser default
|
||||
// timer granularity, heavier scheduling contention than Linux/macOS) blew
|
||||
// through and failed the test on an otherwise-unrelated PR merge.
|
||||
result := tool.Run(context.Background(), map[string]any{
|
||||
"task_id": "child_task",
|
||||
"block": true,
|
||||
"timeout": 2000,
|
||||
"timeout": 100,
|
||||
})
|
||||
|
||||
if result.Status != tools.StatusOK || !strings.Contains(result.Output, "output:\nfinished") {
|
||||
|
||||
@@ -79,25 +79,6 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
content := string(contentBytes)
|
||||
occurrences := strings.Count(content, oldString)
|
||||
|
||||
// CRLF fallback: read_file normalizes \r\n → \n before presenting content to
|
||||
// the model, so the model's old_string will use \n line endings. When the raw
|
||||
// file uses \r\n (common on Windows), the \n-based old_string won't match.
|
||||
// Detect this and transparently normalize: if the direct match fails, translate
|
||||
// old_string's line endings to \r\n and search again. If found, use the CRLF-translated
|
||||
// old_string and new_string for the replacement to preserve the file's existing EOL style
|
||||
// without rewriting EOLs in unrelated parts of the file.
|
||||
if occurrences == 0 && strings.Contains(content, "\r\n") && !strings.Contains(oldString, "\r\n") {
|
||||
crlfOldString := strings.ReplaceAll(oldString, "\n", "\r\n")
|
||||
normalizedOccurrences := strings.Count(content, crlfOldString)
|
||||
if normalizedOccurrences > 0 {
|
||||
occurrences = normalizedOccurrences
|
||||
oldString = crlfOldString
|
||||
if !strings.Contains(newString, "\r\n") {
|
||||
newString = strings.ReplaceAll(newString, "\n", "\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if occurrences == 0 {
|
||||
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
|
||||
}
|
||||
|
||||
@@ -630,16 +630,6 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
|
||||
if session.output.consumeTruncated() {
|
||||
truncated = true
|
||||
}
|
||||
// A background process that keeps writing output faster than this
|
||||
// loop can drain it (a chatty dev server, a watch-mode rebuild loop,
|
||||
// a crash-restart spew) would otherwise never let drainString return
|
||||
// empty, so the deadline check below — only reached on an empty
|
||||
// drain — would never fire. That turns `wait` into an unbounded
|
||||
// block regardless of what the caller asked for. Check it here too,
|
||||
// so continuous output can never starve the timeout.
|
||||
if time.Now().After(deadline) {
|
||||
return finish()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if session.doneClosed() {
|
||||
|
||||
@@ -450,65 +450,6 @@ func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectRespectsDeadlineUnderContinuousOutput asserts collect() returns
|
||||
// close to its requested deadline even while output keeps arriving. Before
|
||||
// the corresponding fix, the deadline was only checked in the branch reached
|
||||
// when drainString returns empty — a background process producing output
|
||||
// fast/continuously enough to keep the buffer perpetually non-empty could
|
||||
// theoretically starve that check indefinitely. This synthetic writer
|
||||
// (8 goroutines, tight loop) did not reliably reproduce that starvation under
|
||||
// Go's scheduler in practice — the reader still won the race often enough —
|
||||
// so this test doesn't prove the old code could hang; it just pins down the
|
||||
// intended behavior (bounded by wait) going forward.
|
||||
func TestCollectRespectsDeadlineUnderContinuousOutput(t *testing.T) {
|
||||
session := &execSession{
|
||||
id: 1000,
|
||||
output: newExecOutputBuffer(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
// Several concurrent writers, each pushing a decent-sized chunk in a tight
|
||||
// loop: a single slow writer lets the reader win the race often enough
|
||||
// (the runtime schedules a gap between writes) to mask the bug. Enough
|
||||
// parallel writers keep the buffer non-empty essentially continuously,
|
||||
// closer to a real chatty process whose PTY reads land in bursts.
|
||||
const writers = 8
|
||||
chunk := []byte(strings.Repeat("x", 256))
|
||||
wg.Add(writers)
|
||||
for i := 0; i < writers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
session.output.Write(chunk)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
})
|
||||
|
||||
const wait = 200 * time.Millisecond
|
||||
start := time.Now()
|
||||
_, _ = session.collect(context.Background(), wait)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Generous slack over `wait` for scheduling jitter under a continuously
|
||||
// writing goroutine — this must stay a small multiple of wait, not
|
||||
// "however long the writer keeps going" (which is what the bug produced:
|
||||
// this test would hang past the 30s test timeout without the fix).
|
||||
if elapsed > 3*wait {
|
||||
t.Fatalf("collect took %v under continuous output, want close to the %v deadline", elapsed, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
|
||||
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
|
||||
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
|
||||
|
||||
@@ -104,8 +104,8 @@ func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
|
||||
if property, ok := search.Parameters().Properties["query"]; !ok || property.Type != "string" {
|
||||
t.Fatalf("web_search must expose a string query property, got %#v", search.Parameters().Properties["query"])
|
||||
}
|
||||
if safety := search.Safety(); safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto {
|
||||
t.Fatalf("web_search safety = %#v, want prompt and advertised in auto", safety)
|
||||
if safety := search.Safety(); safety.Permission != PermissionAllow || safety.AdvertiseInAuto {
|
||||
t.Fatalf("web_search safety = %#v, want allow without prompt-advertise override", safety)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,14 +81,13 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
// Hosted search sends model-provided query text to a configured network
|
||||
// backend. Keep it visible in auto mode, but guard execution through the
|
||||
// normal permission flow like web_fetch.
|
||||
// Hosted search is a read-only discovery action. It remains marked as
|
||||
// network for metadata, but sandbox network policy is the shell egress
|
||||
// boundary; web_search keeps its own result-domain filtering safeguards.
|
||||
safety: Safety{
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionPrompt,
|
||||
Reason: "Sends model-provided search query text to the configured web search backend.",
|
||||
AdvertiseInAuto: true,
|
||||
SideEffect: SideEffectNetwork,
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Performs a web search over the network.",
|
||||
},
|
||||
},
|
||||
backend: backend,
|
||||
@@ -96,8 +95,7 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
}
|
||||
|
||||
// RunWithSandbox follows the normal web_search path. The sandbox network policy
|
||||
// gates sandboxed shell egress; this in-process hosted search tool is guarded by
|
||||
// the permission flow plus backend and result-domain safeguards.
|
||||
// gates sandboxed shell egress, not this in-process hosted search tool.
|
||||
func (tool webSearchTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
|
||||
return tool.Run(ctx, args)
|
||||
}
|
||||
|
||||
@@ -116,52 +116,17 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchSafetyPromptsForHostedSearchButAdvertisesInAuto(t *testing.T) {
|
||||
func TestWebSearchSafetyAllowsHostedSearchWithoutPrompt(t *testing.T) {
|
||||
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
|
||||
safety := tool.Safety()
|
||||
if safety.SideEffect != SideEffectNetwork {
|
||||
t.Fatalf("side effect = %s, want network", safety.SideEffect)
|
||||
}
|
||||
if safety.Permission != PermissionPrompt {
|
||||
t.Fatalf("permission = %s, want prompt", safety.Permission)
|
||||
if safety.Permission != PermissionAllow {
|
||||
t.Fatalf("permission = %s, want allow", safety.Permission)
|
||||
}
|
||||
if !safety.AdvertiseInAuto {
|
||||
t.Fatal("web_search should be advertised in auto mode while still requiring permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRegistryRequiresPermissionBeforeBackendCall(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
|
||||
registry := NewRegistry()
|
||||
registry.Register(newWebSearchToolWithBackend(backend))
|
||||
|
||||
res := registry.Run(context.Background(), "web_search", map[string]any{"query": "private workspace detail"})
|
||||
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected permission error, got %s: %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "Permission required for web_search") {
|
||||
t.Fatalf("expected permission-required output, got %q", res.Output)
|
||||
}
|
||||
if backend.gotQuery != "" {
|
||||
t.Fatalf("backend must not be called before permission, got query %q", backend.gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRegistryRunsAfterPermissionGranted(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{{Title: "T", URL: "https://x.test"}}}
|
||||
registry := NewRegistry()
|
||||
registry.Register(newWebSearchToolWithBackend(backend))
|
||||
|
||||
res := registry.RunWithOptions(context.Background(), "web_search", map[string]any{"query": "go errors"}, RunOptions{
|
||||
PermissionGranted: true,
|
||||
})
|
||||
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected ok, got %s: %s", res.Status, res.Output)
|
||||
}
|
||||
if backend.gotQuery != "go errors" {
|
||||
t.Fatalf("backend query = %q, want %q", backend.gotQuery, "go errors")
|
||||
if safety.AdvertiseInAuto {
|
||||
t.Fatal("web_search should not need the prompt-tool auto advertisement override")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -250,29 +250,6 @@ func TestEditFileToolReplacesExactStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolReplacesCRLF(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "code.go")
|
||||
writeTestFile(t, path, "const a = 1\r\nconst b = 2\r\n")
|
||||
|
||||
result := NewEditFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "code.go",
|
||||
"old_string": "const a = 1\nconst b = 2",
|
||||
"new_string": "const a = 42\nconst b = 24",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("expected edit ok, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "const a = 42\r\nconst b = 24\r\n" {
|
||||
t.Fatalf("unexpected edited content: %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFileToolEmitsUnifiedDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "code.go"), "const a = 1\nconst b = 2\n")
|
||||
|
||||
@@ -56,13 +56,6 @@ func pasteFromClipboardCmd() tea.Cmd {
|
||||
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
|
||||
// the paste; empty content is a no-op.
|
||||
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
|
||||
// A paste is a deliberate action, same as a keypress or click — it means
|
||||
// the user moved on to something else, so it disarms a stale Esc
|
||||
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
|
||||
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
|
||||
// do it once here, before any early return, so both the bracketed-paste
|
||||
// and right-click-paste paths are covered uniformly.
|
||||
m = m.disarmCancelConfirmation()
|
||||
if content == "" {
|
||||
// Empty text clipboard — the user may have pasted a screenshot.
|
||||
// Probe the OS clipboard for image content asynchronously.
|
||||
|
||||
@@ -490,16 +490,16 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// picker calls this when a model from a non-active provider is chosen, so the
|
||||
// picker can list every saved provider and switch across them (like a unified
|
||||
// provider+model selector). The key is loaded from the encrypted store / env.
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string) {
|
||||
if m.pending {
|
||||
return m, "Model\nCannot switch providers while a run is active.", nil
|
||||
return m, "Model\nCannot switch providers while a run is active."
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session."
|
||||
}
|
||||
target, ok := m.savedProviderByName(providerName)
|
||||
if !ok {
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName)
|
||||
}
|
||||
target = m.profileWithCredential(target)
|
||||
target.Model = strings.TrimSpace(modelID)
|
||||
@@ -508,11 +508,11 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
// was deleted/unreadable the marker can still be set, and building a keyless
|
||||
// provider would only fail later with a 401. Local/no-auth providers need no key.
|
||||
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
|
||||
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`."
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
if err != nil {
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}})
|
||||
}
|
||||
m.provider = next
|
||||
m.providerProfile = target
|
||||
@@ -524,19 +524,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
_, _ = config.SetActiveProvider(m.userConfigPath, target.Name)
|
||||
_, _ = config.SetProviderModel(m.userConfigPath, target.Name, target.Model)
|
||||
}
|
||||
// Warm discovery for the provider we just switched to, same as Init() does
|
||||
// for the provider active at launch — otherwise the context-usage gauge has
|
||||
// no window for this provider until /model happens to be opened separately.
|
||||
var cmds []tea.Cmd
|
||||
if hasDescriptor {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, target); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, target.BaseURL, target.Model); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model), tea.Batch(cmds...)
|
||||
return m, fmt.Sprintf("Model\nSwitched to %s · %s", target.Name, target.Model)
|
||||
}
|
||||
|
||||
// profileWithCredential fills a profile's APIKey for provider construction the same
|
||||
|
||||
@@ -467,7 +467,7 @@ func providerNextActionLines(profile config.ProviderProfile, snapshot zerocomman
|
||||
}
|
||||
|
||||
func providerProfileHasCredential(profile config.ProviderProfile) bool {
|
||||
return profile.HasConfiguredCredential()
|
||||
return strings.TrimSpace(profile.APIKey) != "" || profile.APIKeyStored || strings.TrimSpace(profile.AuthHeaderValue) != ""
|
||||
}
|
||||
|
||||
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
|
||||
|
||||
@@ -281,9 +281,9 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/theme",
|
||||
usage: "/theme [list|auto|name]",
|
||||
usage: "/theme [auto|dark|light]",
|
||||
group: commandGroupSession,
|
||||
description: "Pick a color theme (no arg opens the picker; auto detects the terminal background).",
|
||||
description: "Switch or show the color theme (auto-detects terminal background).",
|
||||
kind: commandTheme,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,29 +27,3 @@ func TestContextWindowSegment(t *testing.T) {
|
||||
t.Fatalf("gauge = %q, want 161K/200K · 81%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusLineDoesNotDuplicateTokenFigureNextToGauge: the gauge's "used"
|
||||
// figure and the plain usage segment's token count both read the exact same
|
||||
// latestUsageTokens value — once the gauge is showing it, the usage segment
|
||||
// must fall back to cost-only, or the same number renders twice side by
|
||||
// side in the footer (e.g. "◔ 161K/200K · 81% │ 161K tok").
|
||||
func TestStatusLineDoesNotDuplicateTokenFigureNextToGauge(t *testing.T) {
|
||||
m := newModel(t.Context(), Options{ModelName: "claude-sonnet-4.5"})
|
||||
if _, err := m.usageTracker.Record(usage.RecordInput{
|
||||
ModelID: "claude-sonnet-4.5",
|
||||
Usage: zeroruntime.Usage{InputTokens: 160_000, OutputTokens: 1000},
|
||||
}); err != nil {
|
||||
t.Fatalf("Record: %v", err)
|
||||
}
|
||||
|
||||
status := plainRender(t, m.statusLine(110))
|
||||
if !strings.Contains(status, "161K/200K") {
|
||||
t.Fatalf("status line = %q, expected the context gauge to render", status)
|
||||
}
|
||||
if strings.Count(status, "161K") != 1 {
|
||||
t.Fatalf("status line = %q, the 161K token figure must appear exactly once, not duplicated next to the gauge", status)
|
||||
}
|
||||
if strings.Contains(status, "tok") {
|
||||
t.Fatalf("status line = %q, the plain token segment should be suppressed once the gauge shows the same figure", status)
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
// file_view.go is the drill-in view for a touched file, opened from the FILES
|
||||
// sidebar section (files_panel.go). It reuses the subchat pattern: while
|
||||
// active, the chat column's body swaps to this file's content — the sidebar,
|
||||
// composer, and scroll engine keep working unchanged (transcriptBodyItems is
|
||||
// the single source the viewport, renderer, and hit-testers all read, so
|
||||
// swapping there keeps every consumer consistent). Two modes:
|
||||
//
|
||||
// diff (default) — the file's edit cards from this session, full-depth
|
||||
// full — the file as it stands on disk, syntax highlighted, with
|
||||
// gutter markers on the lines this session's diffs added
|
||||
//
|
||||
// d/f switch modes (with an empty composer), Esc returns to the chat at the
|
||||
// scroll position it was left at.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fileViewMaxLines caps the full-file mode so a giant generated file can't
|
||||
// freeze a render; the tail collapses into a "… N more lines" trailer.
|
||||
const fileViewMaxLines = 4000
|
||||
|
||||
const (
|
||||
fileViewDiff = iota
|
||||
fileViewFull
|
||||
)
|
||||
|
||||
// fileViewState manages the drill-in view for a touched file. When active, the
|
||||
// transcript body swaps to the file's diff/content instead of the chat rows.
|
||||
type fileViewState struct {
|
||||
active bool
|
||||
path string // workspace-relative, as carried by changedFiles
|
||||
mode int // fileViewDiff | fileViewFull
|
||||
// parentScrollOffset preserves the chat scroll position so closing the view
|
||||
// returns to the same spot (mirrors subchatState).
|
||||
parentScrollOffset int
|
||||
}
|
||||
|
||||
// openFileView activates the drill-in for path in diff mode. Opening from an
|
||||
// already-open view (clicking another FILES row) keeps the original saved chat
|
||||
// scroll position rather than saving the file view's own offset as "parent".
|
||||
// Re-opening the file that is ALREADY being viewed is a no-op: a stray
|
||||
// re-click must not bounce the user from full mode back to diff or reset
|
||||
// their scroll position.
|
||||
func (m model) openFileView(path string) model {
|
||||
if m.fileView.active && m.fileView.path == path {
|
||||
return m
|
||||
}
|
||||
if !m.fileView.active {
|
||||
m.fileView.parentScrollOffset = m.chatScrollOffset
|
||||
}
|
||||
m.fileView.active = true
|
||||
m.fileView.path = path
|
||||
m.fileView.mode = fileViewDiff
|
||||
// A file only the git sweep knows about (bash/subagent mutation) has no edit
|
||||
// cards to stack — open straight on the full file instead of a placeholder.
|
||||
if len(m.fileViewResultRows()) == 0 {
|
||||
m.fileView.mode = fileViewFull
|
||||
}
|
||||
m.chatScrollOffset = 0
|
||||
m = m.clearHover() // bodyY numbering differs between the file body and the chat
|
||||
return m
|
||||
}
|
||||
|
||||
// exitFileView deactivates the view and restores the chat scroll position.
|
||||
func (m model) exitFileView() model {
|
||||
if !m.fileView.active {
|
||||
return m
|
||||
}
|
||||
m.chatScrollOffset = m.fileView.parentScrollOffset
|
||||
m.fileView = fileViewState{}
|
||||
m = m.clearHover()
|
||||
return m
|
||||
}
|
||||
|
||||
// setFileViewMode switches diff/full, resetting the scroll to the bottom-anchored
|
||||
// start since the two bodies have unrelated heights.
|
||||
func (m model) setFileViewMode(mode int) model {
|
||||
if !m.fileView.active || m.fileView.mode == mode {
|
||||
return m
|
||||
}
|
||||
m.fileView.mode = mode
|
||||
m.chatScrollOffset = 0
|
||||
return m
|
||||
}
|
||||
|
||||
// fileViewNavBar renders the single-line header shown in place of the pinned
|
||||
// title bar while the view is active: the path plus the key hints. One line
|
||||
// exactly, so every scrollableTranscriptFrame computed against it agrees with
|
||||
// the title bar's geometry.
|
||||
func (m model) fileViewNavBar(width int) string {
|
||||
mode := "diff"
|
||||
other := "f full"
|
||||
if m.fileView.mode == fileViewFull {
|
||||
mode = "full"
|
||||
other = "d diff"
|
||||
}
|
||||
left := zeroTheme.accent.Render("← "+truncatePathLeft(m.fileView.path, maxInt(8, width/2))) +
|
||||
zeroTheme.faint.Render(" · "+mode)
|
||||
right := zeroTheme.faint.Render(other + " · esc back")
|
||||
return fitStyledLine(joinHeaderLine(left, right, width), width)
|
||||
}
|
||||
|
||||
// fileViewBodyItems builds the body items the transcript machinery renders
|
||||
// while the view is active — one pre-rendered block, so scrolling and height
|
||||
// accounting flow through the exact same path as chat rows.
|
||||
func (m model) fileViewBodyItems(width int) []transcriptBodyItem {
|
||||
var block string
|
||||
if m.fileView.mode == fileViewFull {
|
||||
block = m.renderFileViewFull(width)
|
||||
} else {
|
||||
block = m.renderFileViewDiff(width)
|
||||
}
|
||||
return []transcriptBodyItem{transcriptBlockBodyItem(transcriptBodyItemRow, -1, block)}
|
||||
}
|
||||
|
||||
// fileViewResultRows returns the transcript's tool-result rows that touched the
|
||||
// viewed file, in chronological order.
|
||||
func (m model) fileViewResultRows() []transcriptRow {
|
||||
var rows []transcriptRow
|
||||
for _, row := range m.transcript {
|
||||
if row.kind != rowToolResult {
|
||||
continue
|
||||
}
|
||||
for _, p := range row.changedFiles {
|
||||
if p == m.fileView.path {
|
||||
rows = append(rows, row)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// renderFileViewDiff renders the session's edits to the file as its full-depth
|
||||
// tool cards (bodyCap 0, the detailed-view depth), stacked chronologically —
|
||||
// the same cards the chat shows, so the diffs read identically in both places.
|
||||
func (m model) renderFileViewDiff(width int) string {
|
||||
rows := m.fileViewResultRows()
|
||||
if len(rows) == 0 {
|
||||
return zeroTheme.faint.Render("No recorded edits for this file in this session.")
|
||||
}
|
||||
rc := buildRowContext(m.transcript)
|
||||
opts := cardRenderOptions{bodyCap: 0, cwd: m.cwd}
|
||||
var b strings.Builder
|
||||
for i, row := range rows {
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if len(rows) > 1 {
|
||||
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("edit %d of %d", i+1, len(rows))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(m.renderRowModeUncached(row, width, rc, opts))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderFileViewFull renders the file as it currently stands on disk, syntax
|
||||
// highlighted, with a line-number gutter and an accent ▎ marker on the lines
|
||||
// this session's diffs added (matched by exact text — an approximation that
|
||||
// tolerates later drift; a stale marker just doesn't highlight).
|
||||
func (m model) renderFileViewFull(width int) string {
|
||||
target := m.fileView.path
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(m.cwd, target)
|
||||
}
|
||||
// Stream the read and stop at the cap: os.ReadFile would load a multi-GB
|
||||
// file wholesale before any truncation, which is the exact render freeze
|
||||
// fileViewMaxLines exists to prevent.
|
||||
file, err := os.Open(target)
|
||||
if err != nil {
|
||||
return zeroTheme.faint.Render("Could not read file: " + err.Error())
|
||||
}
|
||||
defer file.Close()
|
||||
var lines []string
|
||||
truncated := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for scanner.Scan() {
|
||||
if len(lines) == fileViewMaxLines {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
if len(lines) == 0 {
|
||||
return zeroTheme.faint.Render("Could not read file: " + err.Error())
|
||||
}
|
||||
truncated = true // e.g. a single over-long line mid-file: show what we have
|
||||
}
|
||||
|
||||
changed := m.fileViewChangedLines()
|
||||
gutterW := len(fmt.Sprintf("%d", len(lines)))
|
||||
textBudget := maxInt(8, width-gutterW-3) // gutter + space + marker column
|
||||
// Highlight with an effectively-infinite measure so the highlighter never
|
||||
// wraps — output lines stay 1:1 with file lines and the gutter numbering
|
||||
// can't desync. Each line is then truncated to the column budget below.
|
||||
display, ok := highlightCodeForPath(lines, m.fileView.path, 1<<20, nil)
|
||||
if !ok || len(display) != len(lines) {
|
||||
display = lines // no lexer for this path: render plain
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, line := range display {
|
||||
line = fitStyledLine(line, textBudget)
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
marker := " "
|
||||
if changed[strings.TrimSpace(lines[i])] {
|
||||
marker = zeroTheme.accent.Render("▎")
|
||||
}
|
||||
b.WriteString(zeroTheme.faintest.Render(fmt.Sprintf("%*d ", gutterW, i+1)))
|
||||
b.WriteString(marker)
|
||||
b.WriteString(line)
|
||||
}
|
||||
if truncated {
|
||||
// No exact remaining-line count: computing one would require reading the
|
||||
// rest of the file, defeating the bounded read above.
|
||||
b.WriteString("\n")
|
||||
b.WriteString(zeroTheme.faint.Render(fmt.Sprintf("… more lines (file truncated at %d for display)", len(lines))))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// fileViewChangedLines collects the trimmed text of every line the session's
|
||||
// diffs ADDED to the viewed file, for the full-mode gutter markers. Very short
|
||||
// lines ("}", "return", ")") are skipped: text-matching them would mark every
|
||||
// unrelated occurrence across the file, which misleads far more than a missing
|
||||
// marker on a brace line ever could.
|
||||
func (m model) fileViewChangedLines() map[string]bool {
|
||||
changed := map[string]bool{}
|
||||
for _, row := range m.fileViewResultRows() {
|
||||
for _, line := range strings.Split(row.detail, "\n") {
|
||||
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
|
||||
continue
|
||||
}
|
||||
if text := strings.TrimSpace(strings.TrimPrefix(line, "+")); len(text) >= 4 {
|
||||
changed[text] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// TestFileViewOpenExitRestoresScroll: opening saves the chat scroll position,
|
||||
// resets it for the file body, and Esc restores it; switching files while open
|
||||
// keeps the ORIGINAL saved position (not the file view's own).
|
||||
func TestFileViewOpenExitRestoresScroll(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.chatScrollOffset = 12
|
||||
|
||||
m = m.openFileView("web/app.js")
|
||||
if !m.fileView.active || m.fileView.mode != fileViewDiff {
|
||||
t.Fatalf("open should activate in diff mode: %+v", m.fileView)
|
||||
}
|
||||
if m.chatScrollOffset != 0 || m.fileView.parentScrollOffset != 12 {
|
||||
t.Fatalf("open should reset scroll and save the parent offset: offset=%d saved=%d", m.chatScrollOffset, m.fileView.parentScrollOffset)
|
||||
}
|
||||
|
||||
m.chatScrollOffset = 5 // scrolled within the file body
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
if m.fileView.parentScrollOffset != 12 {
|
||||
t.Fatalf("switching files must keep the original parent offset, got %d", m.fileView.parentScrollOffset)
|
||||
}
|
||||
|
||||
m = m.exitFileView()
|
||||
if m.fileView.active || m.chatScrollOffset != 12 {
|
||||
t.Fatalf("exit should restore the chat scroll: active=%v offset=%d", m.fileView.active, m.chatScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewEscAndModeKeys: Esc exits the view via the model's key handler;
|
||||
// d/f switch modes while the composer is empty and never while typing.
|
||||
func TestFileViewEscAndModeKeys(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
|
||||
m = updated.(model)
|
||||
if m.fileView.mode != fileViewFull {
|
||||
t.Fatal("f should switch to full mode")
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
|
||||
m = updated.(model)
|
||||
if m.fileView.mode != fileViewDiff {
|
||||
t.Fatal("d should switch back to diff mode")
|
||||
}
|
||||
|
||||
// With text in the composer, d/f type as normal characters.
|
||||
m.input.SetValue("say")
|
||||
updated, _ = m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
|
||||
m = updated.(model)
|
||||
if m.fileView.mode != fileViewDiff {
|
||||
t.Fatal("f while typing must not hijack the composer")
|
||||
}
|
||||
m.input.SetValue("")
|
||||
|
||||
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
if m.fileView.active {
|
||||
t.Fatal("Esc should exit the file view")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewDiffBody: diff mode stacks the file's edit cards chronologically
|
||||
// with "edit N of M" labels; a file with no recorded edits shows the quiet
|
||||
// placeholder.
|
||||
func TestFileViewDiffBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
body := plainRender(t, m.renderFileViewDiff(78))
|
||||
if !strings.Contains(body, "edit 1 of 2") || !strings.Contains(body, "edit 2 of 2") {
|
||||
t.Fatalf("expected chronological edit labels:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "added one") || !strings.Contains(body, "three") {
|
||||
t.Errorf("expected both diffs' content:\n%s", body)
|
||||
}
|
||||
|
||||
m.fileView.path = "never/touched.go"
|
||||
if got := plainRender(t, m.renderFileViewDiff(78)); !strings.Contains(got, "No recorded edits") {
|
||||
t.Errorf("untouched file should show the placeholder, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewFullBody: full mode shows the on-disk content with line numbers
|
||||
// and marks session-added lines with the gutter marker; a missing file degrades
|
||||
// to a readable error line.
|
||||
func TestFileViewFullBody(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("let a = 1\nlet untouched = 0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := filesPanelTestModel()
|
||||
m.cwd = dir
|
||||
m.transcript = append(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "write_file", id: "w9", status: tools.StatusOK,
|
||||
detail: "+let a = 1",
|
||||
changedFiles: []string{"app.js"},
|
||||
})
|
||||
m = m.openFileView("app.js")
|
||||
m = m.setFileViewMode(fileViewFull)
|
||||
|
||||
body := m.renderFileViewFull(78)
|
||||
plain := plainRender(t, body)
|
||||
if !strings.Contains(plain, "1 ") || !strings.Contains(plain, "let untouched = 0") {
|
||||
t.Fatalf("full view should show numbered file content:\n%s", plain)
|
||||
}
|
||||
lines := strings.Split(plain, "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("one rendered line per file line, got %d:\n%s", len(lines), plain)
|
||||
}
|
||||
if !strings.Contains(lines[0], "▎") {
|
||||
t.Errorf("session-added line should carry the gutter marker: %q", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[1], "▎") {
|
||||
t.Errorf("untouched line must not carry the marker: %q", lines[1])
|
||||
}
|
||||
|
||||
m.fileView.path = "gone.js"
|
||||
if got := plainRender(t, m.renderFileViewFull(78)); !strings.Contains(got, "Could not read file") {
|
||||
t.Errorf("missing file should degrade to an error line, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewSwapsTranscriptBody: while active, transcriptBodyItems returns
|
||||
// the file body (a single block) instead of the chat rows, and the pinned
|
||||
// title bar swaps to the one-line nav bar — the geometry every frame consumer
|
||||
// relies on.
|
||||
func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
|
||||
}
|
||||
nav := plainRender(t, m.pinnedTitleBar(m.chatColumnWidth()))
|
||||
if !strings.Contains(nav, "sidebar.go") || !strings.Contains(nav, "esc back") {
|
||||
t.Fatalf("nav bar should show the path and key hints: %q", nav)
|
||||
}
|
||||
if lines := len(viewLines(m.fileViewNavBar(m.chatColumnWidth()))); lines != 1 {
|
||||
t.Fatalf("nav bar must be exactly one line (title-bar geometry), got %d", lines)
|
||||
}
|
||||
|
||||
// The whole view renders without panicking in both modes and shows the nav.
|
||||
if view := plainRender(t, m.transcriptView()); !strings.Contains(view, "esc back") {
|
||||
t.Fatal("transcript view should carry the file nav bar")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
|
||||
// is open closes the file view first (the subchat owns the single-column view).
|
||||
func TestSubchatEntryClosesFileView(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := filesPanelTestModel()
|
||||
m.sessionStore = store
|
||||
m.swarmSessionMap = map[string]string{"subagent-1": "sess-1"}
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build it", runID: 1},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 1},
|
||||
)
|
||||
m.activeRunID = 1
|
||||
m = m.openFileView("web/app.js")
|
||||
|
||||
width := sidebarWidth(m.width)
|
||||
agents := m.sidebarAgentSelectables(width)
|
||||
if len(agents) == 0 {
|
||||
t.Fatal("expected a clickable agent row")
|
||||
}
|
||||
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
|
||||
updated, _, handled := m.handleTranscriptSelectionMouse(click)
|
||||
if !handled {
|
||||
t.Fatal("agent row click should be handled")
|
||||
}
|
||||
if updated.fileView.active {
|
||||
t.Fatal("entering the subchat should close the file view")
|
||||
}
|
||||
if !updated.subchat.active {
|
||||
t.Fatal("subchat should be active")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangedFilesRehydration: a persisted tool-result payload's changedFiles
|
||||
// restores onto the rehydrated transcript row, so the FILES panel survives
|
||||
// /resume.
|
||||
func TestChangedFilesRehydration(t *testing.T) {
|
||||
events := []sessions.Event{{
|
||||
Type: sessions.EventToolResult,
|
||||
Payload: json.RawMessage(`{"toolCallId":"t1","name":"edit_file","status":"ok","output":"+x","changedFiles":["pkg/a.go","pkg/b.go"]}`),
|
||||
}}
|
||||
rows := transcriptRowsFromSessionEvents(events)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
got := rows[0].changedFiles
|
||||
if len(got) != 2 || got[0] != "pkg/a.go" || got[1] != "pkg/b.go" {
|
||||
t.Fatalf("changedFiles not rehydrated: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
|
||||
// being viewed must not clobber the user's mode or scroll — the old
|
||||
// unconditional openFileView bounced full mode back to diff and reset scroll.
|
||||
func TestOpenFileViewSamePathIsNoOp(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("web/app.js")
|
||||
m = m.setFileViewMode(fileViewFull)
|
||||
m.chatScrollOffset = 7 // scrolled within the file body
|
||||
|
||||
m = m.openFileView("web/app.js")
|
||||
if m.fileView.mode != fileViewFull {
|
||||
t.Fatal("re-opening the same file must keep full mode")
|
||||
}
|
||||
if m.chatScrollOffset != 7 {
|
||||
t.Fatalf("re-opening the same file must keep the scroll, got %d", m.chatScrollOffset)
|
||||
}
|
||||
// A DIFFERENT file still switches (and resets to diff mode as documented).
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
if m.fileView.path != "internal/tui/sidebar.go" || m.fileView.mode != fileViewDiff {
|
||||
t.Fatalf("opening another file should switch views: %+v", m.fileView)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewFullBodyTruncatesLongFile: the full view stops reading at
|
||||
// fileViewMaxLines (bounded read — a giant file must not be loaded wholesale)
|
||||
// and appends the truncation trailer.
|
||||
func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var b strings.Builder
|
||||
for i := 0; i < fileViewMaxLines+50; i++ {
|
||||
fmt.Fprintf(&b, "line %d\n", i)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.txt"), []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := filesPanelTestModel()
|
||||
m.cwd = dir
|
||||
m.gitTouched = []gitSweepFile{{path: "big.txt"}}
|
||||
m = m.openFileView("big.txt")
|
||||
|
||||
plain := plainRender(t, m.renderFileViewFull(80))
|
||||
lines := strings.Split(plain, "\n")
|
||||
if len(lines) != fileViewMaxLines+1 { // capped content + the trailer line
|
||||
t.Fatalf("rendered %d lines, want %d content lines + 1 trailer", len(lines), fileViewMaxLines)
|
||||
}
|
||||
if !strings.Contains(lines[len(lines)-1], "truncated") {
|
||||
t.Fatalf("expected the truncation trailer, got %q", lines[len(lines)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
|
||||
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
|
||||
// (Esc exiting the view instead of reaching the prompt's deny handling).
|
||||
func TestFileViewKeysDeferToBlockingModal(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("web/app.js")
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: agent.PermissionRequest{ToolName: "write_file"},
|
||||
decide: func(agent.PermissionDecision) {},
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: 'f', Text: "f"})
|
||||
m = updated.(model)
|
||||
if m.fileView.mode != fileViewDiff {
|
||||
t.Fatal("f with a permission prompt up must not switch file-view modes")
|
||||
}
|
||||
updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
m = updated.(model)
|
||||
if !m.fileView.active {
|
||||
t.Fatal("Esc with a permission prompt up must not exit the file view")
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// files_git_sweep.go fills the FILES sidebar's blind spot: mutations that
|
||||
// bypass the file tools entirely — bash/exec_command scaffolding (npm create,
|
||||
// go generate, heredoc writes) and subagents editing the shared workspace. None
|
||||
// of those produce a changedFiles-carrying tool result, so the transcript-derived
|
||||
// roster (files_panel.go) never sees them. The sweep asks git instead: a
|
||||
// baseline `git status --porcelain` snapshot is taken at startup (Init), and a
|
||||
// re-run after each command tool result / turn end reports any NEWLY dirty
|
||||
// paths, which merge into the roster with a diffstat from `git diff --numstat`.
|
||||
// Pre-existing dirty state stays in the baseline and never shows; a non-git
|
||||
// workspace fails the first command and the sweep silently stays off.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// gitSweepTimeout bounds each background git invocation so a hung index lock
|
||||
// can never stall message delivery (the command runs off the update goroutine).
|
||||
const gitSweepTimeout = 3 * time.Second
|
||||
|
||||
// gitSweepFile is one workspace file git reports as dirty: workspace-relative
|
||||
// path (git's own output convention, matching changedFiles), whether it is new
|
||||
// (untracked/added), and the tracked diffstat when known (0/0 for untracked).
|
||||
type gitSweepFile struct {
|
||||
path string
|
||||
created bool
|
||||
adds int
|
||||
dels int
|
||||
}
|
||||
|
||||
// gitSweepMsg carries one sweep's result. baseline marks the startup snapshot
|
||||
// (recorded as "pre-existing, never show") vs a live re-check (merged into the
|
||||
// roster). ok is false when git failed (not a repo, no git binary) — the
|
||||
// handler then marks the sweep unavailable rather than retrying every turn.
|
||||
type gitSweepMsg struct {
|
||||
baseline bool
|
||||
ok bool
|
||||
files []gitSweepFile
|
||||
}
|
||||
|
||||
// gitSweepCmd runs the status+numstat pair off the update goroutine. cwd is the
|
||||
// workspace root the TUI resolves paths against.
|
||||
func gitSweepCmd(parent context.Context, cwd string, baseline bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, gitSweepTimeout)
|
||||
defer cancel()
|
||||
// --untracked-files=all enumerates files inside new directories; plain
|
||||
// porcelain collapses them to "dir/", which is useless as a FILES row.
|
||||
status, err := defaultPRCommandRunner(ctx, cwd, "git", "status", "--porcelain", "--untracked-files=all")
|
||||
if err != nil {
|
||||
return gitSweepMsg{baseline: baseline, ok: false}
|
||||
}
|
||||
files := parseGitPorcelain(status)
|
||||
// Diffstat for tracked modifications; untracked files have no diff to
|
||||
// stat. -z makes the output machine-parseable: NUL-terminated records, no
|
||||
// path quoting, and renames as explicit preimage/postimage fields instead
|
||||
// of the brace-mangled "src/{old => new}" a plain --numstat emits (which
|
||||
// would never match a porcelain path). Best-effort: a failure (e.g. unborn
|
||||
// HEAD in a fresh repo) keeps the file list with zero counts rather than
|
||||
// dropping the sweep.
|
||||
if numstat, err := defaultPRCommandRunner(ctx, cwd, "git", "diff", "HEAD", "--numstat", "-z"); err == nil {
|
||||
stats := parseGitNumstat(numstat)
|
||||
for i := range files {
|
||||
if counts, ok := stats[files[i].path]; ok {
|
||||
files[i].adds, files[i].dels = counts[0], counts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return gitSweepMsg{baseline: baseline, ok: true, files: files}
|
||||
}
|
||||
}
|
||||
|
||||
// parseGitPorcelain parses `git status --porcelain` v1 output into sweep files.
|
||||
// Format: two status columns, a space, then the path ("XY path"); renames show
|
||||
// "old -> new" (keep the new side); untracked entries are "?? path".
|
||||
func parseGitPorcelain(out string) []gitSweepFile {
|
||||
var files []gitSweepFile
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
code, path := line[:2], strings.TrimSpace(line[3:])
|
||||
if to, _, found := cutRename(path); found {
|
||||
path = to
|
||||
}
|
||||
path = unquoteGitPath(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
files = append(files, gitSweepFile{
|
||||
path: path,
|
||||
created: code == "??" || strings.Contains(code, "A"),
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// cutRename splits a porcelain rename value "old -> new", returning the new
|
||||
// path. found is false for ordinary (non-rename) paths.
|
||||
func cutRename(path string) (string, string, bool) {
|
||||
if idx := strings.Index(path, " -> "); idx >= 0 {
|
||||
return path[idx+4:], path[:idx], true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// unquoteGitPath strips the quotes git wraps around paths with special
|
||||
// characters ("web/my file.js"). Escapes inside are left as-is — such a path
|
||||
// still identifies the file well enough for a sidebar row.
|
||||
func unquoteGitPath(path string) string {
|
||||
if len(path) >= 2 && strings.HasPrefix(path, `"`) && strings.HasSuffix(path, `"`) {
|
||||
return path[1 : len(path)-1]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// parseGitNumstat parses `git diff --numstat -z` output into path → [added,
|
||||
// deleted]. -z records are NUL-terminated "added\tdeleted\tpath"; a rename/copy
|
||||
// instead ends the counts record with an EMPTY path ("added\tdeleted\t") and
|
||||
// appends two more NUL-terminated fields, preimage then postimage — the stats
|
||||
// key is the postimage (the path porcelain/changedFiles report). Paths are
|
||||
// never quoted or brace-mangled in -z mode. Binary files report "-" counts and
|
||||
// are skipped.
|
||||
func parseGitNumstat(out string) map[string][2]int {
|
||||
stats := map[string][2]int{}
|
||||
fields := strings.Split(out, "\x00")
|
||||
for i := 0; i < len(fields); i++ {
|
||||
parts := strings.SplitN(fields[i], "\t", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
adds, errA := strconv.Atoi(parts[0])
|
||||
dels, errD := strconv.Atoi(parts[1])
|
||||
if errA != nil || errD != nil {
|
||||
continue
|
||||
}
|
||||
path := parts[2]
|
||||
if path == "" {
|
||||
// Rename/copy record: the next two fields are preimage, postimage.
|
||||
if i+2 >= len(fields) {
|
||||
break // truncated output — drop the partial record
|
||||
}
|
||||
path = fields[i+2]
|
||||
i += 2
|
||||
}
|
||||
if path != "" {
|
||||
stats[path] = [2]int{adds, dels}
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// handleGitSweepMsg folds a sweep result into the model: the baseline snapshot
|
||||
// records what was already dirty before this TUI session (those paths never
|
||||
// show), a live sweep upserts newly dirty paths into gitTouched in first-seen
|
||||
// order. A failed sweep marks git unavailable so no further sweeps are issued.
|
||||
func (m model) handleGitSweepMsg(msg gitSweepMsg) model {
|
||||
m.gitSweepInFlight = false
|
||||
if !msg.ok {
|
||||
m.gitSweepUnavailable = true
|
||||
if m.gitFileBaseline == nil {
|
||||
m.gitFileBaseline = map[string]bool{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
if msg.baseline {
|
||||
baseline := make(map[string]bool, len(msg.files))
|
||||
for _, f := range msg.files {
|
||||
baseline[f.path] = true
|
||||
}
|
||||
m.gitFileBaseline = baseline
|
||||
return m
|
||||
}
|
||||
for _, f := range msg.files {
|
||||
if m.gitFileBaseline[f.path] {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for i := range m.gitTouched {
|
||||
if m.gitTouched[i].path == f.path {
|
||||
m.gitTouched[i] = f
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// Copy-on-append: model copies share the backing array, so an in-place
|
||||
// append from two update branches could alias. Rebuilding is cheap at
|
||||
// sidebar scale.
|
||||
m.gitTouched = append(append([]gitSweepFile(nil), m.gitTouched...), f)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// maybeGitSweep issues a live sweep when one is useful and none is running:
|
||||
// the baseline exists (Init's snapshot answered), git works here, and the
|
||||
// workspace is known. Returns the (possibly nil) command to batch.
|
||||
func (m model) maybeGitSweep() (model, tea.Cmd) {
|
||||
if m.gitSweepInFlight || m.gitSweepUnavailable || m.gitFileBaseline == nil || strings.TrimSpace(m.cwd) == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.gitSweepInFlight = true
|
||||
return m, gitSweepCmd(m.ctx, m.cwd, false)
|
||||
}
|
||||
|
||||
// gitTouchedFiles adapts the sweep results to the roster's touchedFile shape,
|
||||
// for merging under the transcript-derived entries (files_panel.go), newest
|
||||
// detection first — gitTouched upserts in first-seen order, so iterating it in
|
||||
// reverse keeps the merged FILES tail consistent with the roster's
|
||||
// newest-first ordering. No transcript row backs them (lastRowIndex -1), so
|
||||
// selecting one skips the scroll/tint and the drill-in opens on the full file.
|
||||
func (m model) gitTouchedFiles() []touchedFile {
|
||||
if len(m.gitTouched) == 0 {
|
||||
return nil
|
||||
}
|
||||
files := make([]touchedFile, 0, len(m.gitTouched))
|
||||
for i := len(m.gitTouched) - 1; i >= 0; i-- {
|
||||
f := m.gitTouched[i]
|
||||
files = append(files, touchedFile{
|
||||
path: f.path,
|
||||
created: f.created,
|
||||
adds: f.adds,
|
||||
dels: f.dels,
|
||||
lastRowIndex: -1,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseGitPorcelain(t *testing.T) {
|
||||
out := " M web/app.js\n?? web/new.css\nA docs/added.md\nR old.go -> pkg/new.go\n?? \"web/my file.js\"\n\n"
|
||||
files := parseGitPorcelain(out)
|
||||
if len(files) != 5 {
|
||||
t.Fatalf("expected 5 entries, got %d: %+v", len(files), files)
|
||||
}
|
||||
byPath := map[string]gitSweepFile{}
|
||||
for _, f := range files {
|
||||
byPath[f.path] = f
|
||||
}
|
||||
if f := byPath["web/app.js"]; f.created {
|
||||
t.Error("modified file must not read as created")
|
||||
}
|
||||
if f := byPath["web/new.css"]; !f.created {
|
||||
t.Error("untracked file should read as created")
|
||||
}
|
||||
if f := byPath["docs/added.md"]; !f.created {
|
||||
t.Error("index-added file should read as created")
|
||||
}
|
||||
if _, ok := byPath["pkg/new.go"]; !ok {
|
||||
t.Errorf("rename should keep the new path: %+v", files)
|
||||
}
|
||||
if _, ok := byPath["web/my file.js"]; !ok {
|
||||
t.Errorf("quoted path should unquote: %+v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGitNumstat feeds the exact `git diff --numstat -z` record shapes:
|
||||
// plain "added\tdeleted\tpath\0", binary "-\t-\tpath\0", and the rename form
|
||||
// "added\tdeleted\t\0preimage\0postimage\0" — the stats must key the postimage
|
||||
// (the path porcelain/changedFiles report), which the old newline parser lost
|
||||
// (plain --numstat brace-mangles renames, so they never matched a roster path).
|
||||
func TestParseGitNumstat(t *testing.T) {
|
||||
out := "12\t3\tweb/app.js\x00" +
|
||||
"-\t-\tassets/logo.png\x00" +
|
||||
"4\t0\t\x00old.go\x00pkg/new.go\x00" +
|
||||
"1\t1\tweb/my file.js\x00" // -z paths are never quoted
|
||||
stats := parseGitNumstat(out)
|
||||
if got := stats["web/app.js"]; got != [2]int{12, 3} {
|
||||
t.Errorf("web/app.js = %v, want [12 3]", got)
|
||||
}
|
||||
if _, ok := stats["assets/logo.png"]; ok {
|
||||
t.Error("binary '-' entries must be skipped")
|
||||
}
|
||||
if got := stats["pkg/new.go"]; got != [2]int{4, 0} {
|
||||
t.Errorf("renamed file must key the postimage path = %v, want [4 0]", got)
|
||||
}
|
||||
if _, ok := stats["old.go"]; ok {
|
||||
t.Error("the rename preimage must not appear as its own entry")
|
||||
}
|
||||
if got := stats["web/my file.js"]; got != [2]int{1, 1} {
|
||||
t.Errorf("path with spaces = %v, want [1 1] (no quoting in -z mode)", got)
|
||||
}
|
||||
// Truncated rename record (missing postimage) must not panic or mis-key.
|
||||
if got := parseGitNumstat("4\t0\t\x00old.go"); len(got) != 0 {
|
||||
t.Errorf("truncated rename record should be dropped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitSweepMergeAndBaseline: the baseline snapshot hides pre-existing dirty
|
||||
// paths; live sweeps upsert only new ones; a failed sweep latches unavailable.
|
||||
func TestGitSweepMergeAndBaseline(t *testing.T) {
|
||||
m := model{}
|
||||
m = m.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: true, files: []gitSweepFile{{path: "dirty-before.go"}}})
|
||||
if !m.gitFileBaseline["dirty-before.go"] {
|
||||
t.Fatal("baseline should record pre-existing dirty paths")
|
||||
}
|
||||
|
||||
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{
|
||||
{path: "dirty-before.go", adds: 9},
|
||||
{path: "kanban/board.tsx", created: true, adds: 120},
|
||||
}})
|
||||
if len(m.gitTouched) != 1 || m.gitTouched[0].path != "kanban/board.tsx" {
|
||||
t.Fatalf("only newly dirty paths should merge: %+v", m.gitTouched)
|
||||
}
|
||||
|
||||
// Re-sweep updates stats in place, no duplicate.
|
||||
m = m.handleGitSweepMsg(gitSweepMsg{ok: true, files: []gitSweepFile{{path: "kanban/board.tsx", created: true, adds: 150, dels: 2}}})
|
||||
if len(m.gitTouched) != 1 || m.gitTouched[0].adds != 150 {
|
||||
t.Fatalf("re-sweep should upsert stats: %+v", m.gitTouched)
|
||||
}
|
||||
|
||||
failed := model{}
|
||||
failed = failed.handleGitSweepMsg(gitSweepMsg{baseline: true, ok: false})
|
||||
if !failed.gitSweepUnavailable {
|
||||
t.Fatal("a failed sweep should latch unavailable")
|
||||
}
|
||||
if _, cmd := failed.maybeGitSweep(); cmd != nil {
|
||||
t.Fatal("no further sweeps once unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeGitSweepGating: no sweep before the baseline answers, none while one
|
||||
// is in flight, and the in-flight flag sets when one is issued.
|
||||
func TestMaybeGitSweepGating(t *testing.T) {
|
||||
m := model{cwd: "/tmp"}
|
||||
if _, cmd := m.maybeGitSweep(); cmd != nil {
|
||||
t.Fatal("no baseline yet: sweep must not run")
|
||||
}
|
||||
m.gitFileBaseline = map[string]bool{}
|
||||
next, cmd := m.maybeGitSweep()
|
||||
if cmd == nil || !next.gitSweepInFlight {
|
||||
t.Fatal("with a baseline, a sweep should be issued and marked in flight")
|
||||
}
|
||||
if _, again := next.maybeGitSweep(); again != nil {
|
||||
t.Fatal("single-flight: no second sweep while one runs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchedFilesMergesGitSweep: git-discovered files append below the
|
||||
// transcript-derived entries, deduped by path (a tool-result entry wins).
|
||||
func TestTouchedFilesMergesGitSweep(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.gitTouched = []gitSweepFile{
|
||||
{path: "kanban/board.tsx", created: true, adds: 120},
|
||||
{path: "web/app.js", adds: 999}, // duplicate of a transcript entry
|
||||
}
|
||||
files := m.touchedFiles()
|
||||
var kanban *touchedFile
|
||||
appCount := 0
|
||||
for i := range files {
|
||||
if files[i].path == "kanban/board.tsx" {
|
||||
kanban = &files[i]
|
||||
}
|
||||
if files[i].path == "web/app.js" {
|
||||
appCount++
|
||||
if files[i].adds == 999 {
|
||||
t.Error("transcript-derived entry should win over the git duplicate")
|
||||
}
|
||||
}
|
||||
}
|
||||
if kanban == nil || !kanban.created || kanban.lastRowIndex != -1 {
|
||||
t.Fatalf("git-only file should merge with created badge and no row anchor: %+v", files)
|
||||
}
|
||||
if appCount != 1 {
|
||||
t.Fatalf("web/app.js should appear exactly once, got %d", appCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenFileViewGitOnlyFallsBackToFull: a file with no edit cards (git sweep
|
||||
// discovery) opens straight on the full-file view instead of an empty diff.
|
||||
func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}}
|
||||
m = m.openFileView("kanban/board.tsx")
|
||||
if m.fileView.mode != fileViewFull {
|
||||
t.Fatal("git-only file should open in full mode")
|
||||
}
|
||||
m = m.exitFileView()
|
||||
m = m.openFileView("web/app.js")
|
||||
if m.fileView.mode != fileViewDiff {
|
||||
t.Fatal("a file with edit cards still opens in diff mode")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGitSweepCmdAgainstRealRepo: end-to-end against a real git repo — the
|
||||
// baseline sees pre-existing dirt, a post-mutation sweep reports the new file
|
||||
// as created and the modified file with its numstat.
|
||||
func TestGitSweepCmdAgainstRealRepo(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
write := func(name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
write("tracked.txt", "one\ntwo\n")
|
||||
run("add", ".")
|
||||
run("commit", "-q", "-m", "seed")
|
||||
write("pre-existing.txt", "dirt\n") // dirty BEFORE the TUI "opens"
|
||||
|
||||
baseline := gitSweepCmd(nil, dir, true)().(gitSweepMsg)
|
||||
if !baseline.ok || len(baseline.files) != 1 || baseline.files[0].path != "pre-existing.txt" {
|
||||
t.Fatalf("baseline should see only the pre-existing dirt: %+v", baseline)
|
||||
}
|
||||
|
||||
// The "agent" now scaffolds via the shell.
|
||||
write("scaffolded.txt", "hello\n")
|
||||
write("tracked.txt", "one\ntwo\nthree\nfour\n")
|
||||
|
||||
sweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
|
||||
if !sweep.ok {
|
||||
t.Fatal("sweep failed")
|
||||
}
|
||||
byPath := map[string]gitSweepFile{}
|
||||
for _, f := range sweep.files {
|
||||
byPath[f.path] = f
|
||||
}
|
||||
if f, ok := byPath["scaffolded.txt"]; !ok || !f.created {
|
||||
t.Fatalf("scaffolded file should report created: %+v", sweep.files)
|
||||
}
|
||||
if f := byPath["tracked.txt"]; f.adds != 2 || f.dels != 0 {
|
||||
t.Fatalf("tracked.txt numstat = +%d −%d, want +2 −0", f.adds, f.dels)
|
||||
}
|
||||
|
||||
// A rename WITH edits: porcelain reports the new path, and the -z numstat
|
||||
// rename record must resolve to the same (postimage) path so the diffstat
|
||||
// actually attaches — the plain --numstat form brace-mangles the path and
|
||||
// the counts were silently lost.
|
||||
run("add", ".")
|
||||
run("commit", "-q", "-m", "second")
|
||||
run("mv", "tracked.txt", "renamed.txt")
|
||||
write("renamed.txt", "one\ntwo\nthree\nfour\nfive\n")
|
||||
renameSweep := gitSweepCmd(nil, dir, false)().(gitSweepMsg)
|
||||
if !renameSweep.ok {
|
||||
t.Fatal("rename sweep failed")
|
||||
}
|
||||
renamed := map[string]gitSweepFile{}
|
||||
for _, f := range renameSweep.files {
|
||||
renamed[f.path] = f
|
||||
}
|
||||
if f, ok := renamed["renamed.txt"]; !ok || f.adds != 1 {
|
||||
t.Fatalf("renamed file should carry its numstat under the new path, got %+v", renameSweep.files)
|
||||
}
|
||||
|
||||
// Not-a-repo → ok=false (the sweep latches off).
|
||||
if msg := gitSweepCmd(nil, t.TempDir(), false)().(gitSweepMsg); msg.ok {
|
||||
t.Fatal("a non-repo should report ok=false")
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
// files_panel.go renders the FILES section of the right context sidebar: the
|
||||
// workspace files this session has touched, newest first, with an A/M badge and
|
||||
// a +added/−removed diffstat per file, plus a pulsing row for the file whose
|
||||
// write is streaming right now. Like the swarm roster (sidebar.go), the touched
|
||||
// set is not separate model state — it is recovered on demand from the
|
||||
// transcript's tool-result rows (their changedFiles), so it survives resume for
|
||||
// free and can never drift from what the chat shows.
|
||||
//
|
||||
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
|
||||
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
|
||||
// to the most recent one; a second click (or a click while the drill-in is
|
||||
// already open) opens the file view (file_view.go). Esc clears the selection.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// maxSidebarFiles caps the FILES rows so the section stays a glanceable set,
|
||||
// not a scrolling log; older files collapse into a "+N more" trailer.
|
||||
const maxSidebarFiles = 6
|
||||
|
||||
// touchedFile is one workspace file the session has mutated, aggregated across
|
||||
// every tool-result row that touched it.
|
||||
type touchedFile struct {
|
||||
path string
|
||||
adds int // total added lines across its diffs
|
||||
dels int // total removed lines across its diffs
|
||||
edits int // number of mutations
|
||||
created bool // first touch created the file (write_file "Created …")
|
||||
failed bool // the latest touch errored
|
||||
lastRowIndex int // transcript index of the most recent result touching it
|
||||
}
|
||||
|
||||
// touchedFiles recovers the session's touched-file roster from the transcript,
|
||||
// most recently touched first. Recomputed on demand (the value-receiver model
|
||||
// can't persist a registry from View), mirroring swarmSpawnedAgents; the scan is
|
||||
// a single pass over the transcript per render, same order as the sidebar's
|
||||
// other sections.
|
||||
func (m model) touchedFiles() []touchedFile {
|
||||
var files []touchedFile
|
||||
index := map[string]int{}
|
||||
for i, row := range m.transcript {
|
||||
if row.kind != rowToolResult || len(row.changedFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
adds, dels := planDiffStat(row.detail)
|
||||
// A multi-file result (apply_patch spanning several files) must not charge
|
||||
// the WHOLE patch's totals to every file it touched — split the diff by its
|
||||
// per-file headers and attribute each file its own counts.
|
||||
var perFile map[string][2]int
|
||||
if len(row.changedFiles) > 1 {
|
||||
perFile = perFileDiffStats(row.detail)
|
||||
}
|
||||
for _, path := range row.changedFiles {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
at, seen := index[path]
|
||||
if !seen {
|
||||
index[path] = len(files)
|
||||
files = append(files, touchedFile{
|
||||
path: path,
|
||||
created: resultRowCreatedFile(row),
|
||||
lastRowIndex: i,
|
||||
})
|
||||
at = len(files) - 1
|
||||
}
|
||||
fileAdds, fileDels := adds, dels
|
||||
if perFile != nil {
|
||||
// Headers were found: use this file's own counts. A path the split
|
||||
// couldn't attribute shows 0/0 rather than the inflated patch totals.
|
||||
counts := perFile[path]
|
||||
fileAdds, fileDels = counts[0], counts[1]
|
||||
}
|
||||
files[at].adds += fileAdds
|
||||
files[at].dels += fileDels
|
||||
files[at].edits++
|
||||
files[at].failed = row.status == tools.StatusError
|
||||
files[at].lastRowIndex = i
|
||||
}
|
||||
}
|
||||
// Most recently touched first: the file being worked on now belongs at the
|
||||
// top, like the sidebar's ACTIVITY feed. Sorted by last touch (not reversed
|
||||
// first-seen order): a file touched early and touched again last must list
|
||||
// first. Stable, so same-row files keep their changedFiles order.
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
return files[i].lastRowIndex > files[j].lastRowIndex
|
||||
})
|
||||
// Merge the git sweep's discoveries (bash/subagent mutations that carry no
|
||||
// changedFiles — files_git_sweep.go) below the transcript-derived entries,
|
||||
// skipping paths a tool result already reported.
|
||||
for _, f := range m.gitTouchedFiles() {
|
||||
if _, seen := index[f.path]; seen {
|
||||
continue
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// perFileDiffStats splits a (possibly multi-file) unified diff into per-path
|
||||
// +added/−removed counts, keyed by the workspace-relative path from each
|
||||
// section's "+++ b/…" header (the "--- a/…" old side when the new side is
|
||||
// /dev/null, i.e. a deletion). Returns nil when the detail carries no file
|
||||
// headers at all (write_file/edit_file previews may be bare hunks) — the caller
|
||||
// then falls back to whole-detail totals, which are correct for a single file.
|
||||
func perFileDiffStats(detail string) map[string][2]int {
|
||||
stats := map[string][2]int{}
|
||||
current, oldPath := "", ""
|
||||
normalize := func(line string, prefix string) string {
|
||||
path := strings.TrimSpace(line[4:])
|
||||
if tab := strings.IndexByte(path, '\t'); tab >= 0 {
|
||||
path = path[:tab] // unified-diff convention: optional timestamp after a tab
|
||||
}
|
||||
path = unquoteGitPath(path)
|
||||
if path == "/dev/null" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(path, prefix)
|
||||
}
|
||||
for _, line := range strings.Split(detail, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
oldPath = normalize(line, "a/")
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
current = normalize(line, "b/")
|
||||
if current == "" {
|
||||
current = oldPath // deletion: charge the removed lines to the old path
|
||||
}
|
||||
case current == "":
|
||||
// Lines before the first header (or in an unattributable section).
|
||||
case strings.HasPrefix(line, "+"):
|
||||
counts := stats[current]
|
||||
counts[0]++
|
||||
stats[current] = counts
|
||||
case strings.HasPrefix(line, "-"):
|
||||
counts := stats[current]
|
||||
counts[1]++
|
||||
stats[current] = counts
|
||||
}
|
||||
}
|
||||
if len(stats) == 0 {
|
||||
return nil
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// resultRowCreatedFile reports whether a tool-result row reads as a file
|
||||
// creation ("tool result: write_file ok Created x (…)"), so the FILES row can
|
||||
// badge it A rather than M. Heuristic on the confirmation text write_file
|
||||
// already emits; a miss just shows M, never breaks anything.
|
||||
func resultRowCreatedFile(row transcriptRow) bool {
|
||||
return strings.Contains(row.text, " Created ")
|
||||
}
|
||||
|
||||
// liveEditingPath is the path of the file whose mutating tool call is streaming
|
||||
// its arguments RIGHT NOW (the same live write streamingToolCallView previews),
|
||||
// or "" when no mutation is in flight. It renders as a pulsing row pinned atop
|
||||
// the FILES section.
|
||||
func (m model) liveEditingPath() string {
|
||||
if m.streamCallDecoder == nil {
|
||||
return ""
|
||||
}
|
||||
switch m.streamCallName {
|
||||
case "write_file", "edit_file", "apply_patch":
|
||||
return m.streamCallDecoder.path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileHit marks a rendered FILES row (by its ABSOLUTE index inside the rendered
|
||||
// sidebar) that is clickable, carrying the file it refers to.
|
||||
type fileHit struct {
|
||||
lineOffset int
|
||||
path string
|
||||
}
|
||||
|
||||
// sidebarFilesHeader renders the FILES section header with the touched count.
|
||||
func (m model) sidebarFilesHeader(width int) string {
|
||||
n := len(m.touchedFiles())
|
||||
if n == 0 {
|
||||
return sidebarHeader("FILES", width)
|
||||
}
|
||||
return sidebarHeaderWithCount("FILES", fmt.Sprintf("%d", n), zeroTheme.muted, width)
|
||||
}
|
||||
|
||||
// sidebarFileLines renders the FILES section body: the live "writing" pulse row
|
||||
// first, then the touched files newest-first, capped at maxSidebarFiles with a
|
||||
// "+N more" trailer. Returns nil when the session has touched nothing (the
|
||||
// caller shows a placeholder). The paired hits slice records each clickable
|
||||
// row's index WITHIN the returned lines.
|
||||
func (m model) sidebarFileLines(width int) ([]string, []fileHit) {
|
||||
files := m.touchedFiles()
|
||||
live := m.liveEditingPath()
|
||||
if len(files) == 0 && live == "" {
|
||||
return nil, nil
|
||||
}
|
||||
room := maxInt(4, width-3)
|
||||
var lines []string
|
||||
var hits []fileHit
|
||||
|
||||
// The file being written this instant: an accent pulse, not yet clickable
|
||||
// (its result row — the diff — doesn't exist until the call completes).
|
||||
if live != "" {
|
||||
lines = append(lines, " "+zeroTheme.accent.Render("●")+" "+
|
||||
zeroTheme.ink.Render(truncatePathLeft(live, room)))
|
||||
}
|
||||
|
||||
// Filter the live row out FIRST so the "+N more" trailer counts only rows
|
||||
// that could actually render — counting the skipped live entry would inflate
|
||||
// the overflow by one.
|
||||
visible := files[:0:0]
|
||||
for _, f := range files {
|
||||
if f.path != live {
|
||||
visible = append(visible, f)
|
||||
}
|
||||
}
|
||||
shown := 0
|
||||
for _, f := range visible {
|
||||
if shown >= maxSidebarFiles {
|
||||
lines = append(lines, " "+zeroTheme.faint.Render(fmt.Sprintf("+%d more", len(visible)-shown)))
|
||||
break
|
||||
}
|
||||
shown++
|
||||
hits = append(hits, fileHit{lineOffset: len(lines), path: f.path})
|
||||
lines = append(lines, m.renderFileRow(f, room))
|
||||
}
|
||||
return lines, hits
|
||||
}
|
||||
|
||||
// renderFileRow renders one touched file: status badge, left-truncated path,
|
||||
// and the +/− diffstat, with the selected file carrying an accent marker so the
|
||||
// selection reads in the sidebar as well as in the chat tint.
|
||||
func (m model) renderFileRow(f touchedFile, room int) string {
|
||||
badge := zeroTheme.muted.Render("M")
|
||||
switch {
|
||||
case f.failed:
|
||||
badge = zeroTheme.red.Render("✗")
|
||||
case f.created:
|
||||
badge = zeroTheme.green.Render("A")
|
||||
}
|
||||
// Reserve the diffstat's width so the path truncates around it.
|
||||
stat := ""
|
||||
if f.adds > 0 || f.dels > 0 {
|
||||
stat = fmt.Sprintf(" +%d −%d", f.adds, f.dels)
|
||||
}
|
||||
pathRoom := maxInt(4, room-len(stat))
|
||||
pathStyle := zeroTheme.muted
|
||||
lead := " "
|
||||
if m.selectedFile == f.path {
|
||||
lead = zeroTheme.accent.Render("▸")
|
||||
pathStyle = zeroTheme.ink
|
||||
}
|
||||
line := lead + badge + " " + pathStyle.Render(truncatePathLeft(f.path, pathRoom))
|
||||
if stat != "" {
|
||||
line += zeroTheme.faintest.Render(stat)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// sidebarFileSelectables returns the clickable FILES rows with their ABSOLUTE
|
||||
// index inside the rendered sidebar. The FILES section renders after PLAN in
|
||||
// renderContextSidebar: AGENTS header + body, blank + PLAN header + body, then
|
||||
// blank + FILES header, then the file rows. The offset accounting mirrors
|
||||
// sidebarPlanSelectables exactly, extended one section down.
|
||||
func (m model) sidebarFileSelectables(width int) []fileHit {
|
||||
lines, hits := m.sidebarFileLines(width)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
agentBody := len(m.sidebarAgentLines(width))
|
||||
if agentBody == 0 {
|
||||
agentBody = 1 // the "no agents spawned" placeholder occupies one line
|
||||
}
|
||||
planBody := len(m.sidebarPlanLines(width))
|
||||
if planBody == 0 {
|
||||
planBody = 1 // the "no active plan" placeholder occupies one line
|
||||
}
|
||||
base := 1 + agentBody + 2 + planBody + 2 // sections above + (blank + FILES header)
|
||||
for i := range hits {
|
||||
hits[i].lineOffset += base
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
|
||||
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
|
||||
// sidebar's height budget (or colliding with the token floor line) never match.
|
||||
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
|
||||
if !m.sidebarActive() {
|
||||
return "", false
|
||||
}
|
||||
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return "", false
|
||||
}
|
||||
sidebarW := sidebarWidth(m.width)
|
||||
if sidebarW <= 0 {
|
||||
return "", false
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
|
||||
x, y := mouseX(msg), mouseY(msg)
|
||||
if x < x0 || x >= x0+sidebarW {
|
||||
return "", false
|
||||
}
|
||||
for _, hit := range m.sidebarFileSelectables(sidebarW) {
|
||||
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
|
||||
return hit.path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
|
||||
// the currently selected file, so its card renders with the selection tint.
|
||||
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
|
||||
if m.selectedFile == "" || row.kind != rowToolResult {
|
||||
return false
|
||||
}
|
||||
for _, path := range row.changedFiles {
|
||||
if path == m.selectedFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lastRowIndexForFile returns the transcript index of the most recent
|
||||
// tool-result row that touched path, or -1.
|
||||
func (m model) lastRowIndexForFile(path string) int {
|
||||
for i := len(m.transcript) - 1; i >= 0; i-- {
|
||||
row := m.transcript[i]
|
||||
if row.kind != rowToolResult {
|
||||
continue
|
||||
}
|
||||
for _, p := range row.changedFiles {
|
||||
if p == path {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// selectFile marks path as the selected file and scrolls the transcript so its
|
||||
// most recent edit card is in view; the card tint comes from the renderers
|
||||
// reading selectedFile (rowTouchesSelectedFile).
|
||||
func (m model) selectFile(path string) model {
|
||||
m.selectedFile = path
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(m.lastRowIndexForFile(path)); ok {
|
||||
m.chatScrollOffset = offset
|
||||
if offset == 0 {
|
||||
m.chatBodyLines = 0
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the
|
||||
// given transcript row's first rendered line at the top of the viewport (with
|
||||
// one line of breathing room), using the same layout metrics the scroll engine
|
||||
// itself uses. ok is false outside alt-screen or when the row isn't rendered
|
||||
// (e.g. skipped/collapsed).
|
||||
func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
|
||||
if rowIndex < 0 || !m.altScreen || m.height <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
startY := -1
|
||||
for _, span := range metrics.spans {
|
||||
if span.kind == transcriptBodyItemRow && span.rowIndex == rowIndex {
|
||||
startY = span.startY
|
||||
break
|
||||
}
|
||||
}
|
||||
if startY < 0 {
|
||||
return 0, false
|
||||
}
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
viewport := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset)
|
||||
// The offset counts lines below the fold: window.start == total-height-offset,
|
||||
// so placing startY at the window top (minus one context line) means
|
||||
// offset = total - height - (startY - 1), clamped to the scroll range.
|
||||
offset := metrics.totalLines() - frame.bodyRect.height - (startY - 1)
|
||||
return clampInt(offset, 0, viewport.maxOffset()), true
|
||||
}
|
||||
|
||||
// truncatePathLeft fits a path into room cells by dropping leading components
|
||||
// ("internal/tui/sidebar.go" → "…/tui/sidebar.go"), keeping the filename — the
|
||||
// part that identifies the file — intact for as long as possible.
|
||||
func truncatePathLeft(path string, room int) string {
|
||||
if room <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(path) <= room {
|
||||
return path
|
||||
}
|
||||
const ellipsis = "…/"
|
||||
parts := strings.Split(path, "/")
|
||||
for start := 1; start < len(parts); start++ {
|
||||
candidate := ellipsis + strings.Join(parts[start:], "/")
|
||||
if len(candidate) <= room {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Even "…/name" overflows: fall back to a right-truncated basename.
|
||||
return truncateRunes(ellipsis+parts[len(parts)-1], room)
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// filesPanelTestModel is sidebarTestModel plus a couple of touched files: one
|
||||
// created, one edited twice (the second touch most recent).
|
||||
func filesPanelTestModel() model {
|
||||
m := sidebarTestModel()
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1", status: tools.StatusOK,
|
||||
text: "tool result: write_file ok Created web/app.js (10 lines).",
|
||||
detail: "+let a = 1\n+let b = 2",
|
||||
changedFiles: []string{"web/app.js"}},
|
||||
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f2", status: tools.StatusOK,
|
||||
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
|
||||
detail: "+added one\n-removed one",
|
||||
changedFiles: []string{"internal/tui/sidebar.go"}},
|
||||
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "f3", status: tools.StatusOK,
|
||||
text: "tool result: edit_file ok Edited internal/tui/sidebar.go.",
|
||||
detail: "+two\n+three\n-gone",
|
||||
changedFiles: []string{"internal/tui/sidebar.go"}},
|
||||
)
|
||||
return m
|
||||
}
|
||||
|
||||
// TestTouchedFilesAggregates: the roster is recovered from tool-result rows,
|
||||
// newest-touch first, with per-file diffstat totals, edit counts, and the
|
||||
// created badge from write_file's confirmation text.
|
||||
func TestTouchedFilesAggregates(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
files := m.touchedFiles()
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 touched files, got %d: %+v", len(files), files)
|
||||
}
|
||||
// sidebar.go was touched last, so it lists first.
|
||||
if files[0].path != "internal/tui/sidebar.go" {
|
||||
t.Fatalf("most recently touched file should list first, got %q", files[0].path)
|
||||
}
|
||||
if files[0].adds != 3 || files[0].dels != 2 || files[0].edits != 2 {
|
||||
t.Errorf("sidebar.go diffstat = +%d −%d over %d edits, want +3 −2 over 2", files[0].adds, files[0].dels, files[0].edits)
|
||||
}
|
||||
if files[0].created {
|
||||
t.Error("edited file must not read as created")
|
||||
}
|
||||
if !files[1].created {
|
||||
t.Error("write_file 'Created …' result should mark the file created")
|
||||
}
|
||||
if files[0].lastRowIndex != len(m.transcript)-1 {
|
||||
t.Errorf("lastRowIndex = %d, want the final transcript row", files[0].lastRowIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarFileLinesRenderAndOverflow: rows carry the badge + left-truncated
|
||||
// path + diffstat; beyond maxSidebarFiles the tail collapses into "+N more";
|
||||
// hits index only real file rows.
|
||||
func TestSidebarFileLinesRenderAndOverflow(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
lines, hits := m.sidebarFileLines(34)
|
||||
joined := plainRender(t, strings.Join(lines, "\n"))
|
||||
if !strings.Contains(joined, "sidebar.go") || !strings.Contains(joined, "app.js") {
|
||||
t.Fatalf("file rows missing paths:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "+3 −2") {
|
||||
t.Errorf("expected aggregated diffstat +3 −2:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "A") || !strings.Contains(joined, "M") {
|
||||
t.Errorf("expected A and M badges:\n%s", joined)
|
||||
}
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("expected 2 clickable hits, got %d", len(hits))
|
||||
}
|
||||
if hits[0].path != "internal/tui/sidebar.go" {
|
||||
t.Errorf("first hit should be the newest file, got %q", hits[0].path)
|
||||
}
|
||||
|
||||
// Overflow: 8 distinct files -> maxSidebarFiles rows + a "+2 more" trailer.
|
||||
over := sidebarTestModel()
|
||||
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
|
||||
over.transcript = append(over.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
|
||||
changedFiles: []string{name + ".go"},
|
||||
})
|
||||
}
|
||||
overLines, overHits := over.sidebarFileLines(34)
|
||||
if len(overHits) != maxSidebarFiles {
|
||||
t.Fatalf("expected %d clickable rows, got %d", maxSidebarFiles, len(overHits))
|
||||
}
|
||||
if got := plainRender(t, overLines[len(overLines)-1]); !strings.Contains(got, "+2 more") {
|
||||
t.Errorf("expected '+2 more' trailer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarFilesLivePulseRow: while a mutating tool call streams its args,
|
||||
// the file it is writing shows as a pinned (unclickable) pulse row.
|
||||
func TestSidebarFilesLivePulseRow(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.streamCallName = "write_file"
|
||||
m.streamCallDecoder = newStreamingDecoder()
|
||||
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
|
||||
|
||||
lines, hits := m.sidebarFileLines(34)
|
||||
if got := plainRender(t, lines[0]); !strings.Contains(got, "web/new.css") {
|
||||
t.Fatalf("live write should pin its path atop FILES, got %q", got)
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if hit.path == "web/new.css" {
|
||||
t.Error("the live row must not be clickable (no result exists yet)")
|
||||
}
|
||||
}
|
||||
// A non-mutating streaming call shows no pulse row.
|
||||
m.streamCallName = "read_file"
|
||||
if m.liveEditingPath() != "" {
|
||||
t.Error("read_file must not read as a live edit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarFileSelectablesMatchRenderedRows: the hit offsets computed by
|
||||
// sidebarFileSelectables must land exactly on the rendered FILES rows in
|
||||
// renderContextSidebar's output — the same invariant the mouse hit-test relies
|
||||
// on. Asserted against the real render, not a re-derivation of the math.
|
||||
func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
width := sidebarWidth(m.width)
|
||||
rendered := m.renderContextSidebar(width, m.height)
|
||||
hits := m.sidebarFileSelectables(width)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected clickable FILES rows")
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if hit.lineOffset >= len(rendered) {
|
||||
t.Fatalf("hit offset %d beyond rendered sidebar (%d lines)", hit.lineOffset, len(rendered))
|
||||
}
|
||||
line := plainRender(t, rendered[hit.lineOffset])
|
||||
base := hit.path[strings.LastIndex(hit.path, "/")+1:]
|
||||
if !strings.Contains(line, base) {
|
||||
t.Errorf("hit for %q points at line %d which reads %q", hit.path, hit.lineOffset, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
|
||||
// it (tint + scroll state, no view swap); the second click on the same file
|
||||
// opens the drill-in; clicking another file while the view is open switches it.
|
||||
func TestFileRowClickSelectsThenOpens(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
width := sidebarWidth(m.width)
|
||||
hits := m.sidebarFileSelectables(width)
|
||||
if len(hits) < 2 {
|
||||
t.Fatal("expected two clickable FILES rows")
|
||||
}
|
||||
x := m.chatColumnWidth() + 3
|
||||
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
|
||||
|
||||
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !handled {
|
||||
t.Fatal("FILES row click should be handled")
|
||||
}
|
||||
if m1.selectedFile != hits[0].path || m1.fileView.active {
|
||||
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
|
||||
}
|
||||
|
||||
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
|
||||
if !m2.fileView.active || m2.fileView.path != hits[0].path {
|
||||
t.Fatalf("second click should open the file view: %+v", m2.fileView)
|
||||
}
|
||||
|
||||
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
|
||||
if !m3.fileView.active || m3.fileView.path != hits[1].path {
|
||||
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
|
||||
}
|
||||
if m3.selectedFile != hits[1].path {
|
||||
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowTouchesSelectedFileTint: only tool-result rows whose changedFiles
|
||||
// carry the selected path tint, and the render cache key differs across
|
||||
// selection states so the tint can't be served stale.
|
||||
func TestRowTouchesSelectedFileTint(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
row := m.transcript[len(m.transcript)-1]
|
||||
if m.rowTouchesSelectedFile(row) {
|
||||
t.Fatal("nothing selected: no row should tint")
|
||||
}
|
||||
m.selectedFile = "internal/tui/sidebar.go"
|
||||
if !m.rowTouchesSelectedFile(row) {
|
||||
t.Fatal("selected file's edit row should tint")
|
||||
}
|
||||
other := m.transcript[len(m.transcript)-3] // web/app.js
|
||||
if m.rowTouchesSelectedFile(other) {
|
||||
t.Fatal("an unrelated file's row must not tint")
|
||||
}
|
||||
|
||||
rc := buildRowContext(m.transcript)
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
selectedKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
|
||||
m.selectedFile = ""
|
||||
plainKey, _ := m.renderRowCacheKey(row, 80, rc, opts, false)
|
||||
if selectedKey == plainKey {
|
||||
t.Error("render cache key must change with the selection state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEscClearsFileSelectionThenNothing: Esc drops an active file selection
|
||||
// (before any run-level action) and is consumed doing so.
|
||||
func TestEscClearsFileSelectionThenNothing(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.selectedFile = "web/app.js"
|
||||
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
if got := updated.(model).selectedFile; got != "" {
|
||||
t.Fatalf("Esc should clear the file selection, still %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePathLeft(t *testing.T) {
|
||||
if got := truncatePathLeft("internal/tui/sidebar.go", 40); got != "internal/tui/sidebar.go" {
|
||||
t.Errorf("short path should pass through, got %q", got)
|
||||
}
|
||||
if got := truncatePathLeft("internal/tui/sidebar.go", 18); got != "…/tui/sidebar.go" {
|
||||
t.Errorf("want …/tui/sidebar.go, got %q", got)
|
||||
}
|
||||
if got := truncatePathLeft("internal/tui/sidebar.go", 14); got != "…/sidebar.go" {
|
||||
t.Errorf("want …/sidebar.go, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchedFilesPerFileDiffStat: a multi-file result (apply_patch spanning
|
||||
// several files) must charge each file its OWN +/- counts from the per-file
|
||||
// diff sections — not the whole patch's totals to every file it touched.
|
||||
func TestTouchedFilesPerFileDiffStat(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
patch := "diff --git a/web/one.js b/web/one.js\n" +
|
||||
"--- a/web/one.js\n+++ b/web/one.js\n@@ -1,1 +1,2 @@\n+added one\n+added two\n" +
|
||||
"diff --git a/web/two.js b/web/two.js\n" +
|
||||
"--- a/web/two.js\n+++ b/web/two.js\n@@ -1,2 +1,1 @@\n-gone one\n-gone two\n-gone three\n"
|
||||
m.transcript = append(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "apply_patch", id: "p1", status: tools.StatusOK,
|
||||
detail: patch,
|
||||
changedFiles: []string{"web/one.js", "web/two.js"},
|
||||
})
|
||||
byPath := map[string]touchedFile{}
|
||||
for _, f := range m.touchedFiles() {
|
||||
byPath[f.path] = f
|
||||
}
|
||||
if f := byPath["web/one.js"]; f.adds != 2 || f.dels != 0 {
|
||||
t.Errorf("web/one.js = +%d −%d, want its own +2 −0 (not the patch totals)", f.adds, f.dels)
|
||||
}
|
||||
if f := byPath["web/two.js"]; f.adds != 0 || f.dels != 3 {
|
||||
t.Errorf("web/two.js = +%d −%d, want its own +0 −3 (not the patch totals)", f.adds, f.dels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchedFilesRetouchOrdersByLastTouch: touch a, then b, then a again — a
|
||||
// must list first. The old "reverse first-seen order" put b first because a's
|
||||
// slot kept its original position.
|
||||
func TestTouchedFilesRetouchOrdersByLastTouch(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
for _, step := range []struct{ id, path string }{
|
||||
{"r1", "a.go"}, {"r2", "b.go"}, {"r3", "a.go"},
|
||||
} {
|
||||
m.transcript = append(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "edit_file", id: step.id, status: tools.StatusOK,
|
||||
changedFiles: []string{step.path},
|
||||
})
|
||||
}
|
||||
files := m.touchedFiles()
|
||||
if len(files) != 2 || files[0].path != "a.go" {
|
||||
t.Fatalf("the re-touched file must list first, got %+v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarFileLinesOverflowExcludesLiveRow: when the live-writing file is
|
||||
// also a touched file, it renders as the pulse row and is skipped from the
|
||||
// list — the "+N more" trailer must not count that skipped entry.
|
||||
func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
|
||||
m.transcript = append(m.transcript, transcriptRow{
|
||||
kind: rowToolResult, tool: "edit_file", id: name, status: tools.StatusOK,
|
||||
changedFiles: []string{name + ".go"},
|
||||
})
|
||||
}
|
||||
// h.go (a touched file) is ALSO the live write: 7 listable files remain,
|
||||
// 6 render, so the trailer is +1 — not +2 (which counted the skipped live row).
|
||||
m.streamCallName = "edit_file"
|
||||
m.streamCallDecoder = newStreamingDecoder()
|
||||
m.streamCallDecoder.feed(`{"path":"h.go","content":"x`)
|
||||
|
||||
lines, _ := m.sidebarFileLines(34)
|
||||
trailer := plainRender(t, lines[len(lines)-1])
|
||||
if !strings.Contains(trailer, "+1 more") {
|
||||
t.Fatalf("overflow must exclude the live row from the count, got %q", trailer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first
|
||||
// write as content, so the FILES pulse is visible before any result row lands.
|
||||
func TestSidebarHasContentForLiveWrite(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now
|
||||
if m.sidebarHasContent() {
|
||||
t.Fatal("session without agents/plan/files should have no sidebar content")
|
||||
}
|
||||
m.streamCallName = "write_file"
|
||||
m.streamCallDecoder = newStreamingDecoder()
|
||||
m.streamCallDecoder.feed(`{"path":"web/new.css","content":"body{}`)
|
||||
if !m.sidebarHasContent() {
|
||||
t.Fatal("a live in-flight write must count as sidebar content")
|
||||
}
|
||||
}
|
||||
@@ -122,8 +122,6 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyEsc))
|
||||
next = updated.(model)
|
||||
if !transcriptContains(next.transcript, "Run cancelled.") {
|
||||
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@ const (
|
||||
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
|
||||
// step index.
|
||||
hoverPlanStep
|
||||
// hoverFileRow: a touched-file row in the sidebar's FILES section, identified
|
||||
// by path.
|
||||
hoverFileRow
|
||||
)
|
||||
|
||||
// hoverTarget identifies the single clickable row (if any) currently under the
|
||||
@@ -37,7 +34,6 @@ type hoverTarget struct {
|
||||
bodyY int // hoverTranscript
|
||||
sessionID string // hoverSidebarAgent
|
||||
stepIndex int // hoverPlanStep
|
||||
filePath string // hoverFileRow
|
||||
}
|
||||
|
||||
// mouseHover reports whether msg is a plain cursor-movement event with NO button
|
||||
@@ -63,9 +59,6 @@ func (m model) updateHoverTarget(msg tea.MouseMsg) model {
|
||||
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
|
||||
}
|
||||
if path, ok := m.fileRowAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
|
||||
}
|
||||
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
||||
// A permission option reuses its OWN existing keyboard-cursor highlight
|
||||
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// TestInitRequestsWindowSize: Bubble Tea documents an initial WindowSizeMsg as
|
||||
// delivered automatically on program start, but that's the terminal
|
||||
// proactively pushing a size — if it's ever missed (a slow/unusual terminal,
|
||||
// a multiplexer, a startup race), nothing else asks again, m.height stays 0
|
||||
// forever, and transcriptView's `if m.altScreen && m.height > 0` gate falls
|
||||
// back to the unpadded, non-fullscreen render path for the whole session (the
|
||||
// alt-screen viewport never gets filled below the actual content). Init must
|
||||
// explicitly request it too, the same way it already explicitly requests the
|
||||
// background color rather than relying solely on an unprompted push.
|
||||
func TestInitRequestsWindowSize(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
|
||||
cmd := m.Init()
|
||||
if cmd == nil {
|
||||
t.Fatal("Init() must return a non-nil command")
|
||||
}
|
||||
|
||||
want := reflect.ValueOf(tea.RequestWindowSize).Pointer()
|
||||
if !batchContainsCmd(t, cmd, want) {
|
||||
t.Error("Init() must request the window size (tea.RequestWindowSize), not rely solely on the terminal's unprompted initial WindowSizeMsg")
|
||||
}
|
||||
}
|
||||
|
||||
// batchContainsCmd reports whether cmd (Init()'s single top-level
|
||||
// tea.Batch(...)) contains a sub-command matching want, compared by pointer
|
||||
// (the same technique TestThemeAutoReProbesBackground uses for
|
||||
// tea.RequestBackgroundColor — reliable for named top-level functions like
|
||||
// tea.RequestWindowSize, not closures). Invokes cmd itself once to unpack the
|
||||
// tea.BatchMsg, but only pointer-compares the sub-commands — never calls
|
||||
// them, since Init()'s batch also carries real closures (e.g. provider model
|
||||
// discovery) that make live network calls and must not fire in a unit test.
|
||||
func batchContainsCmd(t *testing.T, cmd tea.Cmd, want uintptr) bool {
|
||||
t.Helper()
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
if reflect.ValueOf(cmd).Pointer() == want {
|
||||
return true
|
||||
}
|
||||
batch, ok := cmd().(tea.BatchMsg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range batch {
|
||||
if sub != nil && reflect.ValueOf(sub).Pointer() == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
|
||||
bindings: []keybinding{
|
||||
{"Enter", "send the message"},
|
||||
{"Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Esc", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
@@ -124,9 +124,9 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
|
||||
return " " + keyCell + " " + desc
|
||||
}
|
||||
|
||||
// the given terminal width. Vertical centering is handled by the caller's
|
||||
// overlay compositing pipeline (overlayViewportLines in transcriptView).
|
||||
func (m model) renderKeybindingHelpOverlay(width int) string {
|
||||
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
|
||||
// the given terminal dimensions.
|
||||
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
|
||||
@@ -105,55 +105,3 @@ func TestKeybindingGroupsAreWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #419: the `?` help overlay must render ON TOP of the chat (like the model
|
||||
// picker), not REPLACE the whole screen. The old full-screen replace produced
|
||||
// only the centered shortcut block on a blank canvas — no title bar, no
|
||||
// composer. With the overlay open, surrounding chat chrome that is NOT covered
|
||||
// by the centered box (the model title bar at top, the composer at bottom) must
|
||||
// still be present alongside "Keyboard Shortcuts".
|
||||
func TestHelpOverlayCompositesOverChatNotReplacingIt(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 100
|
||||
m.height = 40
|
||||
m.altScreen = true
|
||||
|
||||
base := plainRender(t, m.View()) // no overlay: baseline chrome
|
||||
m.helpOverlay = true
|
||||
over := plainRender(t, m.View())
|
||||
|
||||
if !strings.Contains(over, "Keyboard Shortcuts") {
|
||||
t.Fatalf("help overlay not rendered:\n%s", over)
|
||||
}
|
||||
// Chrome that renders in the baseline (and sits outside the centered overlay
|
||||
// box) must survive behind the overlay. The full-screen replace showed none.
|
||||
for _, marker := range []string{"gpt-4o", "describe a task"} {
|
||||
if !strings.Contains(base, marker) {
|
||||
t.Fatalf("precondition: baseline chat should contain %q:\n%s", marker, base)
|
||||
}
|
||||
if !strings.Contains(over, marker) {
|
||||
t.Fatalf("#419: help replaced the chat instead of overlaying it; %q is gone:\n%s", marker, over)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A populated transcript row also survives behind the overlay (peeking out to
|
||||
// the left of the centered box), proving the chat body — not just the chrome —
|
||||
// is composited under the overlay rather than discarded.
|
||||
func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
m.width = 120
|
||||
m.height = 40
|
||||
m.altScreen = true
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello there this is a chat line"})
|
||||
m.helpOverlay = true
|
||||
|
||||
view := plainRender(t, m.View())
|
||||
if !strings.Contains(view, "Keyboard Shortcuts") {
|
||||
t.Fatalf("help overlay not rendered:\n%s", view)
|
||||
}
|
||||
// The start of the transcript line peeks to the left of the centered box.
|
||||
if !strings.Contains(view, "hello") {
|
||||
t.Fatalf("#419: transcript body was replaced by the help overlay:\n%s", view)
|
||||
}
|
||||
}
|
||||
+114
-391
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
@@ -41,15 +40,6 @@ const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
|
||||
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
|
||||
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
|
||||
// window, same amber footer treatment) so the two "stop it" keybinds feel
|
||||
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
|
||||
// suggestion overlay that had already closed — silently threw away an
|
||||
// in-progress run with no chance to reconsider.
|
||||
const escCancelConfirmDuration = 3 * time.Second
|
||||
const escCancelConfirmText = "Press Esc again to cancel"
|
||||
|
||||
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
|
||||
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
|
||||
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
|
||||
@@ -58,41 +48,40 @@ const dragEdgeScrollInterval = 70 * time.Millisecond
|
||||
const dragEdgeScrollStep = 1
|
||||
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
ctx context.Context
|
||||
cwd string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
userConfigPath string
|
||||
doctorUserConfigPath string
|
||||
projectConfigPath string
|
||||
gitBranch string
|
||||
providerName string
|
||||
modelName string
|
||||
providerProfile config.ProviderProfile
|
||||
savedProviders []config.ProviderProfile
|
||||
provider zeroruntime.Provider
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
registry *tools.Registry
|
||||
sessionStore *sessions.Store
|
||||
sandboxStore *sandbox.GrantStore
|
||||
mcpConfig config.MCPConfig
|
||||
mcpPermissionStore *internalmcp.PermissionStore
|
||||
mcpTokenStore *internalmcp.TokenStore
|
||||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||||
mcpViewStateCache MCPViewState
|
||||
mcpViewStateReady bool
|
||||
mcpCommandSeq int
|
||||
mcpCommandCancel context.CancelFunc
|
||||
sandboxSetupSeq int
|
||||
sandboxSetupInFlight bool
|
||||
doctorCommandSeq int
|
||||
doctorInFlight bool
|
||||
doctorFrame int
|
||||
activeSession sessions.Metadata
|
||||
sessionEvents []sessions.Event
|
||||
// titledSessions records session ids for which a model-generated title has
|
||||
// already been attempted this process, so a finished turn re-fires the title
|
||||
// generator at most once per session (even before its async result lands).
|
||||
@@ -211,22 +200,6 @@ type model struct {
|
||||
// sidebar; when set, the chat reflows to full width. Distinct from the
|
||||
// availability conditions in sidebarAvailable (geometry / mode / overlays).
|
||||
sidebarHidden bool
|
||||
// selectedFile is the touched file selected by clicking its FILES sidebar
|
||||
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
|
||||
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
|
||||
selectedFile string
|
||||
// fileView is the drill-in view for a touched file (file_view.go): while
|
||||
// active the chat column's body shows the file's diff/content instead of the
|
||||
// transcript, mirroring the subchat drill-in.
|
||||
fileView fileViewState
|
||||
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
|
||||
// paths (nil until Init's sweep answers), the newly dirty files discovered by
|
||||
// live sweeps (bash/subagent mutations that carry no changedFiles), the
|
||||
// single-flight guard, and the "not a git repo / no git" latch.
|
||||
gitFileBaseline map[string]bool
|
||||
gitTouched []gitSweepFile
|
||||
gitSweepInFlight bool
|
||||
gitSweepUnavailable bool
|
||||
// swarmDoneAt records when each swarm member was first seen finished (done/
|
||||
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
|
||||
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
|
||||
@@ -323,14 +296,6 @@ type model struct {
|
||||
copyStatusSeq int
|
||||
exitConfirmActive bool
|
||||
exitConfirmSeq int
|
||||
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
|
||||
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
|
||||
// cancelling a running turn. The two are deliberately separate state (not a
|
||||
// shared flag) since they're different actions with different consequences
|
||||
// (quit the app vs. cancel the current run) that are armed by different
|
||||
// keys — Ctrl+C and Esc respectively.
|
||||
cancelConfirmActive bool
|
||||
cancelConfirmSeq int
|
||||
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
|
||||
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
|
||||
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
|
||||
@@ -365,12 +330,6 @@ type model struct {
|
||||
// catalog descriptor ID), so /model shows each provider's real current models —
|
||||
// the same list the provider-setup wizard discovers — not the static catalog.
|
||||
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
|
||||
// ollamaContextWindowByModel holds context-window sizes fetched from a local
|
||||
// Ollama daemon's native /api/show endpoint (keyed by model name), for
|
||||
// custom/local models that have no curated-catalog entry and whose
|
||||
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
|
||||
// see modelContextWindow.
|
||||
ollamaContextWindowByModel map[string]int
|
||||
|
||||
// pendingImages holds image attachments staged by /image for the next user
|
||||
// turn; pendingImageLabels are their display names (base(path)) for the chip
|
||||
@@ -399,12 +358,6 @@ type exitConfirmExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
|
||||
// cancel-a-run confirmation (see cancelConfirmActive).
|
||||
type cancelConfirmExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
|
||||
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
|
||||
// drag moved back into the body, released, or was otherwise stopped since this
|
||||
@@ -635,7 +588,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
}
|
||||
}
|
||||
|
||||
userConfigDir, _ := config.UserConfigDir()
|
||||
userConfigDir, _ := os.UserConfigDir()
|
||||
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
|
||||
|
||||
registry := options.Registry
|
||||
@@ -692,57 +645,56 @@ func newModel(ctx context.Context, options Options) model {
|
||||
notifier.SetFocused(true)
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
composerCursorVisible: true,
|
||||
userConfigPath: options.UserConfigPath,
|
||||
doctorUserConfigPath: doctorUserConfigPath,
|
||||
projectConfigPath: options.ProjectConfigPath,
|
||||
savedProviders: options.SavedProviders,
|
||||
gitBranch: gitBranch(cwd),
|
||||
providerName: options.ProviderName,
|
||||
modelName: options.ModelName,
|
||||
providerProfile: options.ProviderProfile,
|
||||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||||
recapsEnabled: options.RecapsEnabled,
|
||||
provider: options.Provider,
|
||||
newProvider: options.NewProvider,
|
||||
probeProviderHealth: options.ProbeProviderHealth,
|
||||
discoverProviderModels: options.DiscoverProviderModels,
|
||||
registry: registry,
|
||||
sessionStore: sessionStore,
|
||||
sandboxStore: sandboxStore,
|
||||
mcpConfig: options.MCPConfig,
|
||||
mcpPermissionStore: options.MCPPermissionStore,
|
||||
mcpTokenStore: options.MCPTokenStore,
|
||||
mcpCommand: options.MCPCommand,
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME")),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
usageTracker: usageTracker,
|
||||
transcript: initialTranscript(),
|
||||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||||
prService: prService,
|
||||
prState: prService.GetState(),
|
||||
input: input,
|
||||
spinner: runSpinner,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
altScreen: options.AltScreen,
|
||||
liveUsageCounts: map[int]int{},
|
||||
swarmSessionMap: map[string]string{},
|
||||
setup: newSetupState(options.Setup),
|
||||
setupSave: options.Setup.Save,
|
||||
}
|
||||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||||
@@ -807,24 +759,6 @@ func composerBlinkCmd() tea.Cmd {
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
|
||||
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
|
||||
// on program start, so m.height/m.width are normally set before the first
|
||||
// render. But that's the terminal proactively pushing a size — if it's
|
||||
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
|
||||
// nothing else ever asks again: m.height stays its zero value forever,
|
||||
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
|
||||
// unpadded, non-fullscreen render path for the rest of the session, and
|
||||
// the alt-screen viewport never gets filled below the actual content.
|
||||
// Explicitly requesting it here means Zero doesn't depend solely on the
|
||||
// terminal's unprompted push — mirrors the RequestBackgroundColor request
|
||||
// below for the same reason.
|
||||
cmds = append(cmds, tea.RequestWindowSize)
|
||||
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
|
||||
// dirty when the TUI opens is pre-existing state, not this session's work
|
||||
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
|
||||
if strings.TrimSpace(m.cwd) != "" {
|
||||
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
|
||||
}
|
||||
// In auto mode, ask the terminal for its background color; the reply arrives
|
||||
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
|
||||
if m.themeMode == themeAuto {
|
||||
@@ -839,13 +773,6 @@ func (m model) Init() tea.Cmd {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
// The generic discovery above has no source for a local Ollama model's
|
||||
// context window (see ollamaContextWindowDiscoveryCmd); probe its
|
||||
// native /api/show separately so the gauge works for custom/local
|
||||
// Ollama models too, not just ones in the curated catalog.
|
||||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
if m.prService != nil && m.runtimeMessageSink != nil {
|
||||
service := m.prService
|
||||
@@ -922,14 +849,6 @@ func (m model) disarmExitConfirmation() model {
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) disarmCancelConfirmation() model {
|
||||
if m.cancelConfirmActive {
|
||||
m.cancelConfirmActive = false
|
||||
m.cancelConfirmSeq++
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Update routes every message through updateModel, then advances the flush
|
||||
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
|
||||
// instead of printing into terminal scrollback (see flush.go).
|
||||
@@ -1008,11 +927,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.exitConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case cancelConfirmExpiredMsg:
|
||||
if msg.seq == m.cancelConfirmSeq {
|
||||
m.cancelConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case dragEdgeScrollTickMsg:
|
||||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||||
@@ -1060,13 +974,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !keyCtrl(msg, 'c') {
|
||||
m = m.disarmExitConfirmation()
|
||||
}
|
||||
// Mirrors the exit-confirmation reset above: any key that isn't itself
|
||||
// the confirming Esc means the user moved on to something else, so a
|
||||
// later, unrelated Esc must arm a fresh confirmation rather than
|
||||
// silently cancel off a stale press from seconds ago.
|
||||
if !keyIs(msg, tea.KeyEsc) {
|
||||
m = m.disarmCancelConfirmation()
|
||||
}
|
||||
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
|
||||
// other key is swallowed so nothing types into the hidden composer.
|
||||
if m.helpOverlay {
|
||||
@@ -1080,14 +987,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.handleCtrlC()
|
||||
case keyCtrl(msg, 'o'):
|
||||
return m.toggleDetailedTranscript(), nil
|
||||
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
|
||||
// Mode toggle for the file drill-in, only while the composer is empty
|
||||
// (so mid-sentence typing is never hijacked) and no modal is up (so a
|
||||
// permission prompt / ask-user / wizard keeps its own key handling).
|
||||
if keyText(msg) == "f" {
|
||||
return m.setFileViewMode(fileViewFull), nil
|
||||
}
|
||||
return m.setFileViewMode(fileViewDiff), nil
|
||||
case keyCtrl(msg, 'e'):
|
||||
// Release/recapture the mouse so the user can drag-select and copy text
|
||||
// natively (mouse capture otherwise intercepts terminal selection).
|
||||
@@ -1097,29 +996,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
|
||||
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
|
||||
// reaching the run-cancel fallback. Capture whether this press really
|
||||
// is the confirming second Esc BEFORE any of those branches can fire,
|
||||
// then disarm unconditionally: an Esc that gets consumed by one of
|
||||
// them wasn't a confirm, so it must not leave cancelConfirmActive
|
||||
// armed for some later, unrelated Esc to silently act on.
|
||||
wasConfirmingCancel := m.pending && m.cancelConfirmActive
|
||||
m = m.disarmCancelConfirmation()
|
||||
// Subchat view exits on Esc (returns to main chat).
|
||||
if m.subchat.active {
|
||||
m.chatScrollOffset = m.subchat.exit()
|
||||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||||
return m, nil
|
||||
}
|
||||
// File drill-in exits on Esc (returns to the chat at its saved scroll
|
||||
// position); the file stays selected so a second Esc clears that. Only
|
||||
// with no blocking modal up: Esc on a permission prompt / ask-user /
|
||||
// wizard must reach THAT surface's deny/cancel handling below, not
|
||||
// silently close the drill-in behind it.
|
||||
if m.fileView.active && m.noBlockingModal() {
|
||||
return m.exitFileView(), nil
|
||||
}
|
||||
if m.mcpCommandCancel != nil {
|
||||
m.cancelMCPCommand()
|
||||
if m.mcpAddWizard != nil {
|
||||
@@ -1165,45 +1047,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.picker.kind == pickerModel {
|
||||
m.clearModelPickerLoadState()
|
||||
}
|
||||
if m.picker.kind == pickerTheme {
|
||||
// A live theme preview was applied while navigating; restore the
|
||||
// committed palette since Esc dismisses without choosing.
|
||||
m.restoreCommittedTheme()
|
||||
}
|
||||
m.picker = nil
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
return m.dismissSuggestions(), nil
|
||||
}
|
||||
// A selected FILES row clears before anything run-related: the
|
||||
// selection is a passive highlight, so Esc dropping it is cheap and
|
||||
// expected (mirrors how editors clear selection on Esc).
|
||||
if m.selectedFile != "" {
|
||||
m.selectedFile = ""
|
||||
return m, nil
|
||||
}
|
||||
if m.hasQueuedMessage() {
|
||||
return m.clearQueuedMessage(), nil
|
||||
}
|
||||
m.clearComposer()
|
||||
m.clearSuggestions()
|
||||
if m.pending {
|
||||
if wasConfirmingCancel {
|
||||
m.clearComposer()
|
||||
m.cancelRun()
|
||||
return m, nil
|
||||
}
|
||||
// First Esc only arms the confirmation — preserve whatever
|
||||
// draft the user has typed, since nothing has actually been
|
||||
// cancelled yet and they may not press Esc again.
|
||||
m.cancelConfirmActive = true
|
||||
m.cancelConfirmSeq++
|
||||
seq := m.cancelConfirmSeq
|
||||
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
|
||||
return cancelConfirmExpiredMsg{seq: seq}
|
||||
})
|
||||
m.cancelRun()
|
||||
}
|
||||
m.clearComposer()
|
||||
return m, nil
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
if m.transcriptDetailed {
|
||||
@@ -1324,9 +1181,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.deleteQueryRune()
|
||||
// Editing the filter changes which row is highlighted; keep the
|
||||
// theme preview in sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
return m, nil
|
||||
}
|
||||
// On an empty composer, Backspace removes the last attachment chip
|
||||
@@ -1399,7 +1253,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(1)
|
||||
m.picker.move(1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -1441,7 +1295,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMoved(-1)
|
||||
m.picker.move(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.suggestionsActive() {
|
||||
@@ -1500,9 +1354,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if keyPrintable(msg) {
|
||||
m.picker.appendQuery(keyRunes(msg))
|
||||
// Filtering changes the highlighted row; keep the theme preview in
|
||||
// sync with it (no-op for other pickers).
|
||||
m.previewSelectedTheme()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -1669,38 +1520,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// when the loop is already running or there is nothing to animate.
|
||||
return m, m.ensureSpinnerTick()
|
||||
case permissionRequestMsg:
|
||||
// The agent goroutine that raised this request is BLOCKED waiting on the
|
||||
// decision callback, so every branch below must resolve it exactly once —
|
||||
// or store it in pendingPermission, which resolves it on the user's reply.
|
||||
// Dropping a request without resolving parks the run forever (the reported
|
||||
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
|
||||
// nothing will ever signal, with no visible prompt and no network activity.
|
||||
if msg.runID != m.activeRunID {
|
||||
// A superseded/stale run: unblock its parked goroutine now rather than
|
||||
// relying on that run's context cancel to fire first.
|
||||
if msg.decide != nil {
|
||||
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if msg.request.Action != agent.PermissionActionPrompt {
|
||||
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
|
||||
// blocked because it requested additional permissions). The UI has
|
||||
// nothing to ask, so resolve immediately and FAIL CLOSED — never
|
||||
// silently grant access that was never surfaced to the user. (The agent
|
||||
// now marks such elevation requests as prompts, so in practice this is a
|
||||
// defensive backstop; matches the ACP handler's fail-closed contract.)
|
||||
if msg.decide != nil {
|
||||
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
|
||||
promptRow.runID = msg.runID
|
||||
m.transcript = appendTranscriptRow(m.transcript, promptRow)
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
if msg.request.Action == agent.PermissionActionPrompt {
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case askUserRequestMsg:
|
||||
@@ -1780,7 +1610,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
|
||||
m.pending = false
|
||||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||||
// Fully reset the fade state at stream end. The next render
|
||||
// emits the final row in solid ink (no settling animation), and
|
||||
// the pending streamingFadeTickMsg that lands after this point
|
||||
@@ -1878,13 +1707,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
|
||||
}
|
||||
// End-of-turn git sweep: catch file mutations the tool stream couldn't
|
||||
// report (bash scaffolding, subagent edits) so the FILES sidebar is
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||||
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd)
|
||||
return next, tea.Batch(titleCmd, recapCmd, queuedCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapGeneratedMsg:
|
||||
@@ -2035,14 +1859,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
|
||||
m.transcript = appendTranscriptRow(m.transcript, msg.row)
|
||||
m = m.captureStepWork(msg.row)
|
||||
// A finished command tool may have mutated files git can see but no
|
||||
// changedFiles reports (npm create, heredoc writes, subagent edits) —
|
||||
// re-sweep so the FILES sidebar picks them up mid-turn.
|
||||
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
|
||||
var sweep tea.Cmd
|
||||
m, sweep = m.maybeGitSweep()
|
||||
return m, sweep
|
||||
}
|
||||
return m, nil
|
||||
case swarmSessionsMsg:
|
||||
// Merge completed swarm members' session ids so their AGENTS sidebar rows
|
||||
@@ -2073,8 +1889,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case prStateMsg:
|
||||
m.prState = msg.state
|
||||
return m, nil
|
||||
case gitSweepMsg:
|
||||
return m.handleGitSweepMsg(msg), nil
|
||||
case prWatcherStartedMsg:
|
||||
if msg.stop == nil {
|
||||
return m, nil
|
||||
@@ -2097,14 +1911,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.applySetupOAuthDeviceCode(msg)
|
||||
case modelPickerModelsDiscoveredMsg:
|
||||
return m.applyModelPickerModelsDiscovered(msg), nil
|
||||
case ollamaContextWindowDiscoveredMsg:
|
||||
if msg.err == nil && msg.contextWindow > 0 {
|
||||
if m.ollamaContextWindowByModel == nil {
|
||||
m.ollamaContextWindowByModel = map[string]int{}
|
||||
}
|
||||
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
|
||||
}
|
||||
return m, nil
|
||||
case mcpCommandResultMsg:
|
||||
return m.applyMCPCommandResultMessage(msg), nil
|
||||
}
|
||||
@@ -2118,28 +1924,16 @@ func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
} else if m.helpOverlay || !m.transcriptDetailed {
|
||||
// When helpOverlay is active the help panel is composited into the normal
|
||||
// transcript view as a true overlay (scrim + vertical centering), matching
|
||||
// how the suggestion picker / provider wizard / pickers are drawn.
|
||||
content = m.transcriptView()
|
||||
} else {
|
||||
} else if m.helpOverlay {
|
||||
content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height)
|
||||
} else if m.transcriptDetailed {
|
||||
content = m.detailedTranscriptView()
|
||||
} else {
|
||||
content = m.transcriptView()
|
||||
}
|
||||
|
||||
view := tea.NewView(content)
|
||||
view.AltScreen = m.altScreen
|
||||
// Paint the whole frame with the active theme's surface. Zero never paints the
|
||||
// terminal's own canvas, so without this a theme's text falls on the terminal
|
||||
// background — fine when they share polarity, but a light theme's dark text on a
|
||||
// dark terminal (or vice versa) is invisible, and a color theme never shows its
|
||||
// real surface. Painting the panel makes every theme self-contained and legible
|
||||
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
|
||||
// picker) too. Alt-screen only, so inline output never leaves a painted
|
||||
// background behind in the user's scrollback after exit.
|
||||
if m.altScreen {
|
||||
view.BackgroundColor = zeroTheme.bgPanel
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
if m.wantsMouseCapture() {
|
||||
// AllMotion (not CellMotion) is required for hover highlighting: it
|
||||
@@ -2195,11 +1989,6 @@ func (m model) transcriptView() string {
|
||||
return body + footer
|
||||
}
|
||||
|
||||
helpOverlayContent := ""
|
||||
if m.helpOverlay {
|
||||
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
|
||||
}
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
providerOverlay := m.providerWizardOverlay(width)
|
||||
mcpAddOverlay := m.mcpAddWizardOverlay(width)
|
||||
@@ -2207,8 +1996,6 @@ func (m model) transcriptView() string {
|
||||
pickerOverlay := m.pickerOverlay(width)
|
||||
viewportOverlay := ""
|
||||
switch {
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case providerOverlay != "":
|
||||
viewportOverlay = providerOverlay
|
||||
case mcpAddOverlay != "":
|
||||
@@ -2287,12 +2074,6 @@ func (m model) pinnedTitleBar(width int) string {
|
||||
if !m.altScreen || m.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
// The file drill-in replaces the title bar with its nav line (path + key
|
||||
// hints). Both are exactly one line, and every frame computation routes
|
||||
// through here, so the swap never desyncs the viewport geometry.
|
||||
if m.fileView.active {
|
||||
return m.fileViewNavBar(width)
|
||||
}
|
||||
return m.titleBar(width)
|
||||
}
|
||||
|
||||
@@ -2330,18 +2111,15 @@ func (m model) footerView(width int) string {
|
||||
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
|
||||
}
|
||||
footer.WriteString("\n")
|
||||
// A message typed while a run is active is queued for the next turn; show its
|
||||
// preview directly ABOVE the input box (not below), so it reads as "waiting to
|
||||
// send" sitting on top of what you're currently typing.
|
||||
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
|
||||
footer.WriteString(queued)
|
||||
footer.WriteString("\n")
|
||||
}
|
||||
footer.WriteString(m.composerBox(width))
|
||||
if hint := m.composerDescriptionHint(width); hint != "" {
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(hint)
|
||||
}
|
||||
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(queued)
|
||||
}
|
||||
footer.WriteString("\n")
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
@@ -2917,15 +2695,6 @@ const quietWorkingHint = 8 * time.Second
|
||||
// quietGenerationHint returns a "still generating…" cue with an advancing
|
||||
// quiet-timer when the active turn has produced no streamed output for a while,
|
||||
// else "". The advancing number is itself the liveness signal.
|
||||
//
|
||||
// Past half the provider's idle timeout, the cue escalates to name what's
|
||||
// actually happening and when Zero will act on its own: a heartbeating-but-
|
||||
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
|
||||
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
|
||||
// providerio.ContentStallTimeout(idle), but until it fires this exact same
|
||||
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
|
||||
// the ticking number was the only signal, and it looks identical whether real
|
||||
// (if slow) content is coming or nothing ever will.
|
||||
func (m model) quietGenerationHint() string {
|
||||
if m.activeRunID == 0 {
|
||||
return ""
|
||||
@@ -2941,10 +2710,6 @@ func (m model) quietGenerationHint() string {
|
||||
if quiet < quietWorkingHint {
|
||||
return ""
|
||||
}
|
||||
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
|
||||
ceiling := providerio.ContentStallTimeout(idleTimeout)
|
||||
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
|
||||
}
|
||||
return "still generating… " + formatWorkingElapsed(quiet)
|
||||
}
|
||||
|
||||
@@ -3500,20 +3265,14 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
item, ok := picker.current()
|
||||
if !ok {
|
||||
if picker.kind == pickerTheme {
|
||||
// No selectable row (e.g. the filter matched nothing): undo any live
|
||||
// preview so the palette matches the committed m.themeMode.
|
||||
m.restoreCommittedTheme()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
switch picker.kind {
|
||||
case pickerModel:
|
||||
text := ""
|
||||
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
|
||||
// A model from another saved provider: switch provider + model together.
|
||||
m, text, cmd = m.switchProviderModel(owner, item.Value)
|
||||
m, text = m.switchProviderModel(owner, item.Value)
|
||||
} else {
|
||||
m, text = m.handleModelCommand(item.Value)
|
||||
}
|
||||
@@ -3530,20 +3289,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
case pickerTheme:
|
||||
// The hovered palette is already live from the preview; handleThemeCommand
|
||||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||||
text := ""
|
||||
m, text = m.handleThemeCommand(item.Value)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
if m.themeMode == themeAuto {
|
||||
// Re-probe the terminal background so a committed `auto` re-detects
|
||||
// light/dark instead of reusing the preview's reading — mirrors the
|
||||
// text /theme dispatch (M17).
|
||||
return m, tea.RequestBackgroundColor
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
|
||||
@@ -3797,13 +3544,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandTheme:
|
||||
// Bare `/theme` opens the popup picker (live preview on move, apply on
|
||||
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
|
||||
// (or `/theme list`) still runs the text handler directly.
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
m.picker = m.newThemePicker()
|
||||
return m, nil
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleThemeCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
@@ -4067,8 +3807,7 @@ func (m *model) cancelRun() {
|
||||
m.pending = false
|
||||
m.runCancel = nil
|
||||
m.activeRunID = 0
|
||||
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||||
m.pendingPermission = nil
|
||||
m.pendingAskUser = nil
|
||||
// The interim block renders streamingText live; a cancelled run's partial
|
||||
@@ -4393,14 +4132,13 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
}
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
|
||||
text: toolResultRowText(result),
|
||||
tool: result.Name,
|
||||
status: result.Status,
|
||||
detail: toolResultDetail(result),
|
||||
runID: runID,
|
||||
changedFiles: result.ChangedFiles,
|
||||
kind: rowToolResult,
|
||||
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
|
||||
text: toolResultRowText(result),
|
||||
tool: result.Name,
|
||||
status: result.Status,
|
||||
detail: toolResultDetail(result),
|
||||
runID: runID,
|
||||
}
|
||||
// A Task result is shown by the specialist card, and update_plan by the
|
||||
// plan panel/sidebar, so skip both redundant transcript rows.
|
||||
@@ -4554,21 +4292,6 @@ func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest,
|
||||
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
|
||||
}
|
||||
|
||||
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
|
||||
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
|
||||
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
|
||||
// as such; every other non-prompt action — including allow — is DENIED, so the
|
||||
// UI never silently grants access it did not surface for approval.
|
||||
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
|
||||
if action == agent.PermissionActionCancel {
|
||||
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
|
||||
}
|
||||
return agent.PermissionDecision{
|
||||
Action: agent.PermissionDecisionDeny,
|
||||
Reason: "permission request could not be surfaced for approval",
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
|
||||
if m.runtimeMessageSink == nil {
|
||||
return
|
||||
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user