mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533b41495d | ||
|
|
bb66a44ab3 | ||
|
|
6f97c90316 | ||
|
|
f401c66e81 | ||
|
|
949ee43f71 | ||
|
|
2fcea71778 | ||
|
|
5f36349c18 | ||
|
|
e4f760ee8b | ||
|
|
068c5ad3ad | ||
|
|
407a92739f | ||
|
|
d05e9148a7 | ||
|
|
1d8638466c | ||
|
|
305a62c954 | ||
|
|
9134148f4d | ||
|
|
f653dcac36 | ||
|
|
071ea3a495 | ||
|
|
b0abde7d06 | ||
|
|
5658a36627 | ||
|
|
c60ad8729f | ||
|
|
0562e3bef7 | ||
|
|
9bd93c62f8 | ||
|
|
bdb1b0ecd1 | ||
|
|
4266baf222 | ||
|
|
8d0c5feccb | ||
|
|
b63fe696fd | ||
|
|
3fbbd28e4c | ||
|
|
b9ddd6f421 | ||
|
|
aaad4d2712 | ||
|
|
b4275e3504 | ||
|
|
5b2b4dea1a | ||
|
|
b5a78dca6f | ||
|
|
952788f72d | ||
|
|
f915f70e5a | ||
|
|
cdf9d839ae |
No files matched your search
@@ -4,12 +4,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- action.yml
|
||||
- docs/GITHUB_ACTION.md
|
||||
- .github/workflows/zero-action-smoke.yml
|
||||
|
||||
jobs:
|
||||
@@ -29,7 +31,8 @@ jobs:
|
||||
import sys, yaml
|
||||
|
||||
with open("action.yml", "r", encoding="utf-8") as handle:
|
||||
doc = yaml.safe_load(handle)
|
||||
action_raw = handle.read()
|
||||
doc = yaml.safe_load(action_raw)
|
||||
|
||||
assert isinstance(doc, dict), "action.yml must be a mapping"
|
||||
assert doc.get("name"), "action must declare a name"
|
||||
@@ -65,6 +68,19 @@ jobs:
|
||||
assert "--skip-permissions-unsafe" not in serialized, \
|
||||
"action must never pass --skip-permissions-unsafe"
|
||||
|
||||
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
|
||||
assert run_zero is not None, "action.yml must contain a Run ZERO step"
|
||||
run_zero_script = run_zero.get("run") or ""
|
||||
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
|
||||
"Run ZERO must export the provider key for the current step"
|
||||
assert "later steps inherit" not in action_raw.lower(), \
|
||||
"provider keys must not be documented as inherited by later steps"
|
||||
for line in run_zero_script.splitlines():
|
||||
if "GITHUB_ENV" in line and (
|
||||
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
|
||||
):
|
||||
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
|
||||
|
||||
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
|
||||
PY
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
|
||||
Zero is an AI coding agent for your local terminal. It can inspect a repository,
|
||||
@@ -51,6 +53,31 @@ The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
@@ -121,6 +148,13 @@ the key in the wizard:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zero-logo.png" alt="Zero" width="385">
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>一个独属于你的终端编码智能体。</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
|
||||
Zero 是一个用于本地终端的 AI 编码智能体。它可以检查仓库、编辑文件、运行命令、使用浏览器/终端辅助工具,并在你选择模型和权限级别的同时保持持久的本地会话。
|
||||
|
||||
```bash
|
||||
zero
|
||||
zero exec "修复 ./pkg 中失败的测试"
|
||||
zero exec --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
## 为什么选择 Zero
|
||||
|
||||
- **使用你想要的模型。** 支持 OpenAI、Anthropic、Gemini、Groq、OpenRouter、DeepSeek、Mistral、xAI、Qwen、Kimi、GitHub Models、Ollama、LM Studio,或任何 OpenAI/Anthropic 兼容端点。
|
||||
- **保持控制权。** 文件写入、Shell 命令、网络访问和工作区外写入都经过 Zero 的权限和沙箱策略。
|
||||
- **在终端中工作。** TUI 具有模型/提供商选择器、图片输入、斜杠命令、实时计划/工具渲染、回滚滚动、主题以及恢复/分叉支持。
|
||||
- **无 TUI 也能工作。** `zero exec` 可脚本化,支持文本/JSON/stream-JSON I/O、隔离的工作树、规范优先运行,以及用于 CI 的有意义的退出码。
|
||||
- **保持上下文本地化。** 会话存储在磁盘上,可搜索、可恢复,且 Zero 不会作为遥测数据上传。
|
||||
- **可扩展。** 使用 MCP 服务器、技能、插件、钩子和来自同一 CLI 的专业子智能体。
|
||||
|
||||
## 安装
|
||||
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.25+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
|
||||
发布安装器和 npm 包需要已发布的 GitHub Release 资源。如果你在首次公开发布之前进行测试,请从源码构建:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||
```
|
||||
|
||||
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||
|
||||
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
|
||||
|
||||
## 首次运行
|
||||
|
||||
启动 TUI:
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
设置向导帮助你选择提供商和模型。你也可以从命令行配置提供商:
|
||||
|
||||
```bash
|
||||
zero setup
|
||||
zero providers list
|
||||
zero models list
|
||||
zero doctor
|
||||
```
|
||||
|
||||
对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
|
||||
```bash
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama 或 LM Studio,然后使用 `zero setup` 或 `zero providers detect`。
|
||||
|
||||
## 日常使用
|
||||
|
||||
### 交互式 TUI
|
||||
|
||||
```bash
|
||||
zero
|
||||
```
|
||||
|
||||
常用控制:
|
||||
|
||||
| 控制 | 操作 |
|
||||
|---|---|
|
||||
| `Enter` | 发送提示 |
|
||||
| `/` | 打开斜杠命令建议 |
|
||||
| `Shift+Tab` | 切换权限模式 |
|
||||
| `Ctrl+B` | 显示/隐藏侧边栏 |
|
||||
| `Ctrl+C` | 取消或退出 |
|
||||
|
||||
常用斜杠命令:
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `/model`、`/provider` | 切换活动模型/提供商 |
|
||||
| `/spec`、`/plan` | 在构建之前起草和审查计划 |
|
||||
| `/image` | 为视觉模型附加图片 |
|
||||
| `/resume`、`/rewind` | 继续或回滚本地会话 |
|
||||
| `/compact`、`/context` | 管理上下文使用 |
|
||||
| `/permissions`、`/tools` | 检查可用工具和策略 |
|
||||
| `/add-dir` | 为此会话授予额外的写入目录 |
|
||||
| `/theme`、`/doctor`、`/config` | 调整外观和检查设置 |
|
||||
|
||||
### 无头 `exec` 模式
|
||||
|
||||
```bash
|
||||
zero exec "解释 internal/agent/loop.go"
|
||||
zero exec --model claude-sonnet-4.5 "重构配置加载器"
|
||||
zero exec --use-spec "为 API 客户端添加速率限制"
|
||||
zero exec --worktree "在隔离的工作树中尝试迁移"
|
||||
zero exec --resume
|
||||
zero exec --fork <session-id> "尝试另一种方法"
|
||||
```
|
||||
|
||||
编程使用:
|
||||
|
||||
```bash
|
||||
zero exec --input-format stream-json --output-format stream-json < turns.jsonl
|
||||
```
|
||||
|
||||
Stream-JSON 协议文档在 [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md)。
|
||||
|
||||
## 安全模型
|
||||
|
||||
Zero 旨在使副作用可见。
|
||||
|
||||
- 工作区读取默认允许。
|
||||
- 文件写入限制在工作区内,除非你授予其他目录。
|
||||
- Shell 命令、网络访问、破坏性命令和提权操作需要权限授权。
|
||||
- `--add-dir <path>` 和 `/add-dir <path>` 授予额外的写入根目录,而不会给智能体整个文件系统。
|
||||
- 不安全/自主模式是显式选择加入。
|
||||
- 在 Zero 控制的界面上,密钥会从工具输出和日志中被脱敏。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
zero --add-dir ../docs-site
|
||||
zero exec --add-dir ../shared "更新两个仓库"
|
||||
```
|
||||
|
||||
沙箱行为可以通过以下方式检查:
|
||||
|
||||
```bash
|
||||
zero sandbox policy
|
||||
zero sandbox grants list
|
||||
```
|
||||
|
||||
## Web 和本地控制
|
||||
|
||||
Zero 包含本地文件/搜索/编辑/Shell 工具、用于公共 URL 的 `web_fetch`,以及用于额外工具的 MCP 支持。
|
||||
|
||||
对于本地开发服务器,使用 Shell 命令(如通过 `exec_command` 的 `curl`),这样正常的沙箱和权限策略就会生效。长时间运行的命令保持附加到后台终端会话,可以从 TUI 中列出或停止。
|
||||
|
||||
npm 包还包含本地浏览器/终端工具使用的浏览器和终端辅助包。源码构建可以在它们位于 `PATH` 上或在 Zero 的本地控制设置中配置时使用相同的辅助工具。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```text
|
||||
zero 交互式 TUI
|
||||
zero exec 一次性或脚本化智能体运行
|
||||
zero setup 首次运行提供商设置
|
||||
zero auth 支持提供商的 OAuth/登录辅助
|
||||
zero models 模型注册表和能力
|
||||
zero providers 提供商配置和检测
|
||||
zero doctor 设置、密钥和连接检查
|
||||
zero context 上下文预算报告
|
||||
zero repo-map 确定性仓库映射
|
||||
zero repo-info 本地仓库摘要
|
||||
zero search | find 搜索本地会话历史
|
||||
zero sessions 检查、恢复、分叉和回滚会话
|
||||
zero spec 管理规范模式草稿
|
||||
zero specialist 管理专业子智能体
|
||||
zero skills 管理 Markdown 指令技能
|
||||
zero plugins 管理插件
|
||||
zero hooks 管理生命周期钩子
|
||||
zero mcp 管理 MCP 服务器和工具
|
||||
zero serve --mcp 通过 MCP stdio 暴露 Zero 工具
|
||||
zero sandbox 检查沙箱策略和授权
|
||||
zero worktrees 准备隔离的 Git 工作树
|
||||
zero verify 检测和运行本地验证检查
|
||||
zero changes 检查和提交本地 Git 变更
|
||||
zero usage Token 使用量和估算成本
|
||||
zero cron 定时智能体任务
|
||||
zero update 检查更新版本
|
||||
```
|
||||
|
||||
## 外观和无障碍
|
||||
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
|
||||
含义不仅仅依赖于颜色;差异、权限和状态也使用文本或标记符号。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-perf-bench
|
||||
```
|
||||
|
||||
交叉编译示例:
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
- [基准测试](docs/BENCHMARK.md)
|
||||
- [性能](docs/PERFORMANCE.md)
|
||||
- [智能体评估](docs/AGENT_EVALS.md)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献。阅读 [CONTRIBUTING.md](CONTRIBUTING.md),运行相关测试,然后提交一个聚焦的拉取请求。
|
||||
|
||||
安全报告应遵循 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
Zero 基于 [MIT 许可证](LICENSE) 发布。
|
||||
+4
-5
@@ -27,14 +27,14 @@ inputs:
|
||||
api-key:
|
||||
description: >-
|
||||
The provider API key. Pass it from a repository or organization secret;
|
||||
it is exported into the step environment and never written to the log.
|
||||
it is exported only for the ZERO step and never written to the log.
|
||||
required: false
|
||||
default: ""
|
||||
api-key-env:
|
||||
description: >-
|
||||
Name of the environment variable the chosen provider reads its key from
|
||||
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
|
||||
api-key, that variable is exported for the run. Leave the action
|
||||
api-key, that variable is exported only for the ZERO step. Leave the action
|
||||
provider-agnostic by passing the env name your provider expects rather
|
||||
than hardcoding one here.
|
||||
required: false
|
||||
@@ -192,10 +192,9 @@ runs:
|
||||
echo "::error::api-key-env must be a valid environment variable name." >&2
|
||||
exit 2
|
||||
fi
|
||||
# Export in-process so the zero invocation in this same run block sees
|
||||
# it, and append to GITHUB_ENV so any later steps inherit it too.
|
||||
# Export in-process for this Run ZERO step so zero can read it; do
|
||||
# not persist provider credentials to later job steps.
|
||||
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
|
||||
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
|
||||
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
|
||||
|
||||
+21
-1
@@ -54,8 +54,28 @@ const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper. Reinstall the zero package or run `go run ./cmd/zero-release build` from the repository.'
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
|
||||
'by default. Trust the package to run the blocked postinstall:\n' +
|
||||
' bun pm trust @gitlawb/zero (project install)\n' +
|
||||
' bun pm -g trust @gitlawb/zero (global install)\n' +
|
||||
'On Bun versions without `bun pm trust`, add\n' +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ jobs:
|
||||
> provider-neutral: `api-key-env` is the environment variable name the chosen
|
||||
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
|
||||
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
|
||||
> that variable for the run and never prints it. If your repository already
|
||||
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
|
||||
> that variable only for the ZERO step and never prints it. If your repository
|
||||
> already commits a `.zero/config.json` with an active provider, you can omit
|
||||
> `provider`.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -51,8 +52,8 @@ jobs:
|
||||
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
|
||||
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
|
||||
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
|
||||
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
|
||||
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
|
||||
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
|
||||
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
|
||||
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
|
||||
@@ -162,9 +163,10 @@ jobs:
|
||||
## Security notes
|
||||
|
||||
- **Secrets are passed as Action secrets and never logged.** The action exports
|
||||
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
|
||||
environment only. They are never echoed, and ZERO redacts secret-shaped
|
||||
strings from its own output.
|
||||
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
|
||||
is passed only to the Slack post step. They are never echoed or persisted to
|
||||
later workflow steps, and ZERO redacts secret-shaped strings from its own
|
||||
output.
|
||||
- **The sandbox is always active.** This action never passes
|
||||
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
|
||||
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
|
||||
|
||||
@@ -24,6 +24,51 @@ Requirements:
|
||||
- Node.js 18+
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// A shell call that sets sandbox_permissions: with_additional_permissions but
|
||||
// omits (or malforms) additional_permissions can never succeed: the same
|
||||
// validation runs again at grant time regardless of the user's decision. It
|
||||
// must be rejected as a plain tool error BEFORE any permission prompt, not
|
||||
// surfaced as a confusing "approved but denied" prompt outcome.
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The mismatched-flag case (additional_permissions present without the
|
||||
// sandbox_permissions flag) must be rejected the same way.
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionAllow}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status != tools.StatusError {
|
||||
t.Fatalf("status = %q, want error", result.Status)
|
||||
}
|
||||
if promptCalls != 0 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection message must show a concrete, correctly-shaped example: a
|
||||
// model that gets this wrong once (attaching sandbox_permissions out of habit
|
||||
// from an earlier call, without a valid payload) needs enough in the error to
|
||||
// self-correct on retry instead of repeating the identical mistake.
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
|
||||
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "omit sandbox_permissions") {
|
||||
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed additional_permissions payload must still reach the normal
|
||||
// permission prompt: this fix only rejects malformed payloads.
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{
|
||||
ID: "c1",
|
||||
Name: "bash",
|
||||
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
|
||||
`"additional_permissions":{"network":{"enabled":true}}}`,
|
||||
},
|
||||
PermissionModeAuto,
|
||||
Options{
|
||||
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
promptCalls++
|
||||
return PermissionDecision{Action: PermissionDecisionDeny}, nil
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if promptCalls != 1 {
|
||||
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@ import (
|
||||
// the agent loop owns the provider wiring.
|
||||
|
||||
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
|
||||
// when the caller does not specify a preserve count.
|
||||
const defaultCompactionPreserveLast = 8
|
||||
// when the caller does not specify a preserve count. Kept in sync with the replay
|
||||
// reconstruction default in internal/sessions/replay.go.
|
||||
const defaultCompactionPreserveLast = 6
|
||||
|
||||
// compactionTriggerRatio is the fraction of the context window at which
|
||||
// proactive compaction kicks in (top of each turn).
|
||||
const compactionTriggerRatio = 0.8
|
||||
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
|
||||
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
|
||||
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
|
||||
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
|
||||
// summarization, raise it back toward 0.8.
|
||||
const compactionTriggerRatio = 0.7
|
||||
|
||||
// summaryLabel prefixes the injected summary so it is unmistakable in the
|
||||
// transcript (and so tests can assert on it).
|
||||
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -285,67 +284,12 @@ const (
|
||||
noOutputStopSuffix = "to avoid consuming tokens without making progress."
|
||||
)
|
||||
|
||||
// providerEmptyStream reports that a cleanly-completed turn carried NOTHING —
|
||||
// no visible text, no tool calls (real or dropped), and no reasoning signal.
|
||||
// That shape is a transport/backend fault (e.g. the ollama cloud relay
|
||||
// answering 200 with an instantly-empty SSE under load), not model behavior:
|
||||
// a model that is generating always leaves SOME trace (text, a tool call
|
||||
// attempt, or reasoning deltas).
|
||||
func providerEmptyStream(collected zeroruntime.CollectedStream) bool {
|
||||
return strings.TrimSpace(collected.Text) == "" &&
|
||||
len(collected.ToolCalls) == 0 &&
|
||||
collected.DroppedToolCalls == 0 &&
|
||||
len(collected.ReasoningBlocks) == 0 &&
|
||||
!collected.ReasoningEmitted
|
||||
}
|
||||
|
||||
// allEmptyTurnsProviderEmpty reports that every strike toward the no-output
|
||||
// guard was a provider-empty stream, so the stop message should name the
|
||||
// backend fault rather than generic agent no-progress.
|
||||
func (state *guardState) allEmptyTurnsProviderEmpty() bool {
|
||||
return state.emptyTurns > 0 && state.providerEmptyTurns == state.emptyTurns
|
||||
}
|
||||
|
||||
// noOutputStopAnswer is the final answer returned when the no-output guard
|
||||
// stops the run. The turn count is interpolated at the call site.
|
||||
func noOutputStopAnswer(turns int) string {
|
||||
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
|
||||
}
|
||||
|
||||
// providerEmptyStopAnswer is the final answer when every guard strike was a
|
||||
// provider-empty stream: the backend repeatedly answered with contentless
|
||||
// completions (already retried with backoff inside each turn). It tells the
|
||||
// user the truth — the provider failed, not the agent — and what to do next.
|
||||
// detail carries wire-level diagnostics from the last empty attempt
|
||||
// (finish reason, token counts) so the condition is debuggable straight from
|
||||
// the session log — this exact failure class previously took a live traffic
|
||||
// capture to diagnose because nothing about the response shape was recorded.
|
||||
func providerEmptyStopAnswer(turns int, detail string) string {
|
||||
answer := providerEmptyStopPrefix +
|
||||
strconv.Itoa(turns) + " consecutive attempts, each already retried with backoff. " +
|
||||
"The backend is likely rate-limiting or degraded right now — try again shortly, or switch providers/models (zero providers use <name>)."
|
||||
if detail != "" {
|
||||
answer += " [last attempt: " + detail + "]"
|
||||
}
|
||||
return answer
|
||||
}
|
||||
|
||||
// emptyStreamDetail renders the wire-level shape of an empty attempt for
|
||||
// providerEmptyStopAnswer.
|
||||
func emptyStreamDetail(collected zeroruntime.CollectedStream) string {
|
||||
finish := collected.FinishReason
|
||||
if finish == "" {
|
||||
finish = "stop"
|
||||
}
|
||||
return fmt.Sprintf("finish_reason=%s, output_tokens=%d, reasoning_tokens=%d",
|
||||
finish, collected.Usage.OutputTokens, collected.Usage.ReasoningTokens)
|
||||
}
|
||||
|
||||
// providerEmptyStopPrefix is the stable head of providerEmptyStopAnswer, used
|
||||
// by IsNoProgressStop so session titling / resume filtering treat the
|
||||
// provider-empty stop like the other guard answers (not real content).
|
||||
const providerEmptyStopPrefix = "The model provider returned an empty response (no text, no tool calls, no reasoning) on "
|
||||
|
||||
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
|
||||
// (a run that produced no visible text and no tool calls). It matches the EXACT
|
||||
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
|
||||
@@ -356,15 +300,6 @@ const providerEmptyStopPrefix = "The model provider returned an empty response (
|
||||
// and skip its title generation.
|
||||
func IsNoProgressStop(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
// The provider-empty variant: stable prefix + a bare integer count.
|
||||
if rest, ok := strings.CutPrefix(trimmed, providerEmptyStopPrefix); ok {
|
||||
if sep := strings.Index(rest, " consecutive attempts"); sep > 0 {
|
||||
if _, err := strconv.Atoi(rest[:sep]); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
|
||||
return false
|
||||
}
|
||||
@@ -417,7 +352,6 @@ func toolOnlyProgressReminder(turns int) string {
|
||||
// purely from tool-call names and per-turn output, matching what the loop holds.
|
||||
type guardState struct {
|
||||
emptyTurns int
|
||||
providerEmptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
@@ -483,18 +417,8 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
|
||||
if hasToolCalls || hasVisibleText {
|
||||
state.emptyTurns = 0
|
||||
state.providerEmptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
// Track separately whether the empty turn was a PROVIDER-empty stream
|
||||
// (nothing at all came back — not even reasoning) vs a behavioral empty
|
||||
// (the model thought/streamed but committed no answer). When every
|
||||
// strike was provider-empty, the stop message names the backend fault
|
||||
// instead of blaming the agent — the difference between the user
|
||||
// retrying/switching providers and filing a "the agent gives up" bug.
|
||||
if providerEmptyStream(collected) {
|
||||
state.providerEmptyTurns++
|
||||
}
|
||||
}
|
||||
if hasToolCalls && !hasVisibleText {
|
||||
state.toolOnlyTurns++
|
||||
|
||||
@@ -9,23 +9,11 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// emptyTurn is a PROVIDER-empty stream: it completes cleanly carrying nothing
|
||||
// at all (the ollama-cloud 200+[DONE] shape). The loop retries these in-turn
|
||||
// before counting a no-output strike.
|
||||
// emptyTurn is a stream that produces no visible text and no tool calls.
|
||||
func emptyTurn() []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
|
||||
}
|
||||
|
||||
// reasoningOnlyTurn is a BEHAVIORAL empty turn: the model streamed reasoning
|
||||
// (so the provider clearly worked) but committed no text and no tool calls.
|
||||
// These are NOT retried in-turn; they count directly toward the no-output guard.
|
||||
func reasoningOnlyTurn() []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: "thinking…"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// textTurn produces a turn with visible assistant text.
|
||||
func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -57,9 +45,9 @@ func countUserMessagesContaining(messages []zeroruntime.Message, needle string)
|
||||
func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningOnlyTurn(),
|
||||
reasoningOnlyTurn(),
|
||||
reasoningOnlyTurn(),
|
||||
emptyTurn(),
|
||||
emptyTurn(),
|
||||
emptyTurn(),
|
||||
// A 4th turn exists but must never be requested.
|
||||
textTurn("should never reach here"),
|
||||
},
|
||||
@@ -89,10 +77,10 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
|
||||
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningOnlyTurn(),
|
||||
reasoningOnlyTurn(),
|
||||
emptyTurn(),
|
||||
emptyTurn(),
|
||||
textTurn("here is real progress"), // resets the counter and is the final answer
|
||||
reasoningOnlyTurn(),
|
||||
emptyTurn(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -121,11 +109,11 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningOnlyTurn(),
|
||||
reasoningOnlyTurn(),
|
||||
emptyTurn(),
|
||||
emptyTurn(),
|
||||
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // resets counter
|
||||
reasoningOnlyTurn(),
|
||||
reasoningOnlyTurn(),
|
||||
emptyTurn(),
|
||||
emptyTurn(),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
@@ -467,96 +455,3 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
|
||||
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider-empty stream (clean completion carrying nothing at all) is
|
||||
// retried in-turn with backoff before it may count as a no-output strike; when
|
||||
// the backend keeps answering empty, the run stops with a message that names
|
||||
// the PROVIDER fault — not the generic "agent made no progress" text. This is
|
||||
// the exact live failure observed on the ollama cloud relay (HTTP 200 in ~1s,
|
||||
// SSE straight to [DONE]), which silently killed 56% of sessions on the
|
||||
// reporting machine.
|
||||
func TestRunRetriesProviderEmptyStreamsThenStopsWithProviderMessage(t *testing.T) {
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, 9)
|
||||
for i := 0; i < 9; i++ { // 3 strikes × (1 initial + 2 in-turn retries)
|
||||
turns = append(turns, emptyTurn())
|
||||
}
|
||||
provider := &mockProvider{turns: turns}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRequests := maxEmptyTurns * (1 + maxEmptyStreamRetries)
|
||||
if len(provider.requests) != wantRequests {
|
||||
t.Fatalf("expected %d requests (%d strikes × %d attempts), got %d",
|
||||
wantRequests, maxEmptyTurns, 1+maxEmptyStreamRetries, len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "provider returned an empty response") {
|
||||
t.Fatalf("expected the provider-empty stop message, got %q", result.FinalAnswer)
|
||||
}
|
||||
if strings.Contains(result.FinalAnswer, noOutputStopMarker) {
|
||||
t.Fatalf("provider fault must not be reported as generic agent no-progress: %q", result.FinalAnswer)
|
||||
}
|
||||
if !IsNoProgressStop(result.FinalAnswer) {
|
||||
t.Fatal("the provider-empty stop answer must be recognized by IsNoProgressStop (titling/resume filters)")
|
||||
}
|
||||
// Wire-level diagnostics must land in the message (and therefore the
|
||||
// session log) so this failure class is debuggable without a traffic capture.
|
||||
if !strings.Contains(result.FinalAnswer, "output_tokens=0") || !strings.Contains(result.FinalAnswer, "finish_reason=") {
|
||||
t.Fatalf("stop answer must carry last-attempt diagnostics, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// A transient empty response recovers on the in-turn retry: no strike, no stop,
|
||||
// the retried turn's real output is the answer.
|
||||
func TestRunRecoversWhenEmptyStreamRetrySucceeds(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
emptyTurn(), // initial attempt: backend hiccup
|
||||
textTurn("recovered"), // in-turn retry gets the real answer
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests (initial + one retry), got %d", len(provider.requests))
|
||||
}
|
||||
if result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("expected the retried turn's text as final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if result.Turns != 1 {
|
||||
t.Fatalf("an in-turn retry is the SAME turn, want 1 turn, got %d", result.Turns)
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed strikes (provider-empty + behavioral reasoning-only) must NOT claim a
|
||||
// provider fault — the generic no-output message stays.
|
||||
func TestRunMixedEmptyTurnsKeepGenericStopMessage(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningOnlyTurn(), // strike 1: behavioral
|
||||
emptyTurn(), emptyTurn(), emptyTurn(), // strike 2: provider-empty ×(1+2 retries)
|
||||
reasoningOnlyTurn(), // strike 3: behavioral
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, noOutputStopMarker) {
|
||||
t.Fatalf("mixed strikes must use the generic no-output message, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
+120
-112
@@ -25,20 +25,7 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
|
||||
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
|
||||
// the no-output case is retried (a partial turn would duplicate), so this is a
|
||||
// safe recovery for a stalled/dead pooled connection.
|
||||
// maxStreamStallRetries: 3 in-turn re-issues (4 attempts total) approaches
|
||||
// Codex's stream_max_retries=5 posture while staying hard-capped (opencode's
|
||||
// unbounded session retry has produced infinite loops on this same failure).
|
||||
// Affordable now that a mid-stream death is detected at the gap timeout
|
||||
// (~2m) instead of the full 5m idle window.
|
||||
const maxStreamStallRetries = 3
|
||||
|
||||
// maxEmptyStreamRetries caps in-turn re-issues of a request whose stream
|
||||
// completed cleanly but carried nothing at all (providerEmptyStream). Observed
|
||||
// live on the ollama cloud relay under load: HTTP 200 in ~1s with an SSE that
|
||||
// goes straight to [DONE]. The condition is transient backend state, so a
|
||||
// short backoff-retry usually recovers; without it three such responses
|
||||
// arrived inside two seconds and killed the run via the no-output guard.
|
||||
const maxEmptyStreamRetries = 2
|
||||
const maxStreamStallRetries = 2
|
||||
|
||||
const (
|
||||
toolResultMetaControl = "control"
|
||||
@@ -337,35 +324,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
}
|
||||
// Provider-empty retry: the stream completed CLEANLY but carried nothing
|
||||
// — no text, no tool calls, no reasoning. That is not model behavior (a
|
||||
// generating model always leaves some trace); it is a backend answering
|
||||
// with a contentless completion (seen on the ollama cloud relay under
|
||||
// load: 200 + instant [DONE]). Transient, so re-issue with backoff before
|
||||
// letting it count as a no-output strike. Nothing from the empty stream
|
||||
// was forwarded or committed to history, so the retry is clean.
|
||||
for attempt := 1; attempt <= maxEmptyStreamRetries &&
|
||||
collected.Error == "" && !forwardedVisibleText &&
|
||||
providerEmptyStream(collected); attempt++ {
|
||||
if notify := emptyRetryNoticeFor(options); notify != nil {
|
||||
notify(attempt, maxEmptyStreamRetries)
|
||||
}
|
||||
if err := sleepWithContext(ctx, backoffFor(attempt)); err != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
}
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryErr
|
||||
}
|
||||
collected = zeroruntime.CollectStreamWithOptions(ctx, retryStream, forwardingOpts)
|
||||
}
|
||||
if collected.Error != "" {
|
||||
// Route a reissued stream's non-stall error through the SAME recovery as
|
||||
// the initial stream (image-rejection wrapping / context-limit compaction)
|
||||
@@ -412,15 +370,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// A truly-empty turn (no text, no tool calls, no dropped calls) is
|
||||
// counted toward the runaway cap so we stop before burning maxTurns.
|
||||
if guards.observeTurn(collected) {
|
||||
// Every strike being a provider-empty stream means the BACKEND
|
||||
// failed repeatedly (each attempt already retried with backoff
|
||||
// above) — say so, instead of the generic no-progress message
|
||||
// that reads as the agent giving up.
|
||||
if guards.allEmptyTurnsProviderEmpty() {
|
||||
result.FinalAnswer = providerEmptyStopAnswer(result.Turns, emptyStreamDetail(collected))
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
result.FinalAnswer = noOutputStopAnswer(result.Turns)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -867,6 +816,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
}
|
||||
|
||||
// A shell command that sets sandbox_permissions: with_additional_permissions
|
||||
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
|
||||
// is the single source of truth for that shape, and buildPermissionEvent (below)
|
||||
// calls it again to render the prompt's scope text. Check it here, before any
|
||||
// permission prompt is shown: a malformed payload can never be satisfied no
|
||||
// matter what the user decides, so surfacing it as a plain tool error (which the
|
||||
// model can see and retry) is both clearer and avoids presenting a normal-looking
|
||||
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
|
||||
if toolFound && isShellCommandTool(call.Name) {
|
||||
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: tools.StatusError,
|
||||
Output: "Error: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ask_user is intercepted in the loop (like permissions) so the question can
|
||||
// be routed to an interactive front-end instead of blocking inside the tool.
|
||||
// When no front-end is wired up it degrades to the tool's own graceful Run().
|
||||
@@ -2210,6 +2178,18 @@ func buildPermissionEvent(call ToolCall, tool tools.Tool, args map[string]any, p
|
||||
}
|
||||
}
|
||||
|
||||
// A command that explicitly requests additional sandbox permissions
|
||||
// (sandbox_permissions: with_additional_permissions) is an ELEVATION the user
|
||||
// must consent to, even when the base command's sandbox decision was Allow.
|
||||
// shouldRequestPermission blocks on OnPermissionRequest for exactly this case,
|
||||
// so the event MUST be a prompt — otherwise it carried Action=allow while the
|
||||
// loop waited on a decision, and a UI that renders only prompts (the TUI)
|
||||
// dropped it, deadlocking the run. Keep this consistent with
|
||||
// shouldRequestPermission's own condition.
|
||||
if action == PermissionActionAllow && !permissionGranted && shellCommandAdditionalPermissionsRequested(args) {
|
||||
action = PermissionActionPrompt
|
||||
}
|
||||
|
||||
if safety.Permission == tools.PermissionAllow && action == PermissionActionAllow && !grantMatched && block == nil {
|
||||
return PermissionEvent{}, false
|
||||
}
|
||||
@@ -2361,7 +2341,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
}
|
||||
raw, ok := args["additional_permissions"]
|
||||
if !ok || raw == nil {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
|
||||
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
|
||||
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
|
||||
"omit sandbox_permissions entirely and retry",
|
||||
tools.SandboxPermissionsWithAdditionalPermissions)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
@@ -2376,7 +2361,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
|
||||
return sandbox.RequestPermissionProfile{}, true, err
|
||||
}
|
||||
if normalized.Empty() {
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
|
||||
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
|
||||
"additional_permissions must include at least one of network or file_system, for example " +
|
||||
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
|
||||
}
|
||||
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
|
||||
if err != nil {
|
||||
@@ -2509,76 +2496,97 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
|
||||
|
||||
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
|
||||
// deferral there is no mid-session loading, so this is byte-stable across turns
|
||||
// and byte-identical to the pre-deferral output.
|
||||
if !active {
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
for _, tool := range visible {
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, runtimeToolDefinition(tool))
|
||||
}
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions, ""
|
||||
}
|
||||
|
||||
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
|
||||
// deferred tool loads mid-session. The tools block is part of the provider's
|
||||
// cached prefix; if a load reorders it, the cache invalidates from that point
|
||||
// through the system prompt and messages — on EVERY load. Previously the whole
|
||||
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
|
||||
// middle and shifted every later definition. Instead we build three append-only
|
||||
// regions:
|
||||
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
|
||||
// 2. tool_search — always present, right after the stable block;
|
||||
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
|
||||
// transition grows the tail instead of inserting into the eager block.
|
||||
// (tool_search's discovery text still shrinks as tools load, so keeping it and
|
||||
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
|
||||
// load; fully stabilizing the loader's own description is a scoped follow-up.)
|
||||
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
loadedTail := make([]zeroruntime.ToolDefinition, 0)
|
||||
var hiddenTools []tools.Tool
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue // added explicitly between the eager block and the loaded tail
|
||||
}
|
||||
if tools.IsDeferred(tool) {
|
||||
if loaded[name] {
|
||||
loadedTail = append(loadedTail, runtimeToolDefinition(tool))
|
||||
} else {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenTools = append(hiddenTools, tool)
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
eager = append(eager, runtimeToolDefinition(tool))
|
||||
}
|
||||
|
||||
// On the ACTIVE path tool_search is guaranteed runnable (active implies
|
||||
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
|
||||
// EnabledTools allowlist omits it (the operator allowlisted the deferred
|
||||
// tools, not the loader). Expose its full definition whenever it is not
|
||||
// already in the exposed set. This never runs on the inactive path, so the
|
||||
// byte-identical below-threshold output is preserved.
|
||||
discovery := ""
|
||||
if active && len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
if active && !exposedNames[tools.ToolSearchToolName] {
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
} else if active && discovery != "" {
|
||||
for i := range definitions {
|
||||
if definitions[i].Name == tools.ToolSearchToolName {
|
||||
definitions[i].Description = discovery
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
sort.Slice(eager, func(left int, right int) bool {
|
||||
return eager[left].Name < eager[right].Name
|
||||
})
|
||||
sort.Slice(loadedTail, func(left int, right int) bool {
|
||||
return loadedTail[left].Name < loadedTail[right].Name
|
||||
})
|
||||
|
||||
discovery := ""
|
||||
if len(hiddenTools) > 0 {
|
||||
discovery = tools.BuildToolSearchDescription(hiddenTools)
|
||||
}
|
||||
|
||||
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
|
||||
// — even when a non-empty EnabledTools allowlist omits it (the operator
|
||||
// allowlisted the deferred tools, not the loader). It sits right after the stable
|
||||
// eager block and carries the discovery list for still-hidden tools.
|
||||
description := loader.Description()
|
||||
if discovery != "" {
|
||||
description = discovery
|
||||
}
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
|
||||
definitions = append(definitions, eager...)
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: loader.Name(),
|
||||
Description: description,
|
||||
Parameters: schemaToRuntimeMap(loader.Parameters()),
|
||||
})
|
||||
definitions = append(definitions, loadedTail...)
|
||||
|
||||
return definitions, discovery
|
||||
}
|
||||
|
||||
// runtimeToolDefinition renders a tool's advertised definition (name, description,
|
||||
// JSON-schema parameters) as sent to the provider.
|
||||
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
|
||||
return zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
}
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// Cache-stability property: loading a deferred tool must APPEND it after the
|
||||
// always-eager tools, not sort it into the middle of them — otherwise the tool
|
||||
// definitions (the provider's cacheable prefix) shift on every load. The old
|
||||
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
|
||||
|
||||
pos := func(name string) int {
|
||||
for i := range exposed {
|
||||
if exposed[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
|
||||
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
if pos("mcp__srv__beta") != -1 {
|
||||
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
// standing in for bash so buildPermissionEvent can be exercised directly.
|
||||
type promptShellTool struct{}
|
||||
|
||||
func (promptShellTool) Name() string { return "bash" }
|
||||
func (promptShellTool) Description() string { return "run a shell command" }
|
||||
func (promptShellTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (promptShellTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs a shell command"}
|
||||
}
|
||||
func (promptShellTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// A command whose sandbox decision is Allow but that explicitly requests
|
||||
// additional sandbox permissions is an ELEVATION: it must be surfaced as a
|
||||
// prompt, never Action=allow. Previously it carried allow while the loop still
|
||||
// blocked on OnPermissionRequest, and the TUI (which renders only prompts)
|
||||
// dropped it → the run deadlocked forever. This is the exact gpt-5.5
|
||||
// folder-creation shape captured in the reported hang.
|
||||
func TestBuildPermissionEventPromptsForAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_1", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "mkdir foo && cd foo && go mod init foo",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
}
|
||||
// Base command allowed by the sandbox…
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("buildPermissionEvent returned ok=false; the elevation request must produce an event")
|
||||
}
|
||||
if event.Action != PermissionActionPrompt {
|
||||
t.Fatalf("Action = %q, want prompt — an additional-permissions elevation must ask the user, not auto-allow", event.Action)
|
||||
}
|
||||
// Sanity: shouldRequestPermission agrees the loop blocks on this.
|
||||
if !shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be true for an additional-permissions request")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the additional-permissions flag, a sandbox-allowed command is NOT a
|
||||
// prompt (shouldRequestPermission is false; it just runs) — the override must
|
||||
// not over-reach and prompt for ordinary allowed commands.
|
||||
func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
call := ToolCall{ID: "call_2", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "ls"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow}
|
||||
|
||||
event, _ := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if event.Action == PermissionActionPrompt {
|
||||
t.Fatalf("Action = prompt for an ordinary allowed command; want allow (no spurious prompt)")
|
||||
}
|
||||
if shouldRequestPermission(promptShellTool{}, args, false, decision) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
|
||||
// system prompt and the eager tool schemas that ride on EVERY request. They are
|
||||
// set ~10% above the current measured cost, using ApproxTextTokens (the same
|
||||
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
|
||||
// that pushes either past its ceiling must be justified (and the ceiling raised
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
// Minimal render: a model is set (so the session block renders) but no Cwd, so
|
||||
// the workspace map, project guidelines, and repo map are excluded — this is the
|
||||
// fixed base every session pays regardless of the workspace it runs in.
|
||||
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
|
||||
got := ApproxTextTokens(prompt)
|
||||
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
|
||||
if got > maxBaseSystemPromptTokens {
|
||||
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
|
||||
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
|
||||
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
got := estimateToolDefTokens(exposed)
|
||||
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
|
||||
if got > maxEagerToolSchemaTokens {
|
||||
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
|
||||
}
|
||||
}
|
||||
@@ -55,22 +55,6 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyRetryNoticeFor builds a notifier for the loop's provider-empty retry
|
||||
// (a stream that completed cleanly but carried NOTHING — no text, no tool
|
||||
// calls, no reasoning). Distinct wording from the stall notice because nothing
|
||||
// timed out: the backend answered instantly with an empty completion (observed
|
||||
// on the ollama cloud relay under load, and possible on any gateway having a
|
||||
// bad moment). Surfaced through OnReasoning, the non-content channel. Nil when
|
||||
// there is no reasoning sink (the retry still happens silently).
|
||||
func emptyRetryNoticeFor(options Options) reconnectNotifier {
|
||||
if options.OnReasoning == nil {
|
||||
return nil
|
||||
}
|
||||
return func(attempt, max int) {
|
||||
options.OnReasoning(fmt.Sprintf("\n[provider returned an empty response — retrying %d/%d…]\n", attempt, max))
|
||||
}
|
||||
}
|
||||
|
||||
// streamWithReconnect issues request via provider.StreamCompletion and, on a
|
||||
// transient disconnect error, retries the connect up to maxStreamReconnects
|
||||
// times with exponential backoff. It returns the live stream on success, or the
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
}
|
||||
// The first skill is always listed regardless of budget.
|
||||
if !strings.Contains(got, "- skill-0:") {
|
||||
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
}
|
||||
got := skillsContext(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a conventional-commit message."},
|
||||
{Name: " ", Description: "nameless, should be skipped"},
|
||||
{Name: "reviewer"},
|
||||
}})
|
||||
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
|
||||
t.Fatalf("missing available_skills block: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
|
||||
t.Fatalf("missing commit-writer line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "- reviewer\n") {
|
||||
t.Fatalf("reviewer (no description) line missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "nameless") {
|
||||
t.Fatalf("nameless entry should be skipped: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
|
||||
with := buildSystemPrompt(Options{Skills: []SkillInfo{
|
||||
{Name: "commit-writer", Description: "Write a commit message."},
|
||||
}})
|
||||
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
|
||||
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
|
||||
}
|
||||
// Default (no skills) must reproduce the prior prompt: no skills block.
|
||||
without := buildSystemPrompt(Options{})
|
||||
if strings.Contains(without, "<available_skills>") {
|
||||
t.Fatalf("available_skills block must not appear without skills")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// skillsContext lists the reusable skills the model can pull in on demand via the
|
||||
// skill tool, so it invokes the right one on the first try instead of guessing a
|
||||
// name, failing, and reading the error. It mirrors specialistDelegationContext:
|
||||
// names + one-line descriptions only (never skill bodies), and it renders nothing
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
line := "- " + name
|
||||
if desc := strings.TrimSpace(info.Description); desc != "" {
|
||||
line += ": " + truncateForSkillLine(desc)
|
||||
}
|
||||
line += "\n"
|
||||
// Always list at least one skill; past the budget, summarize the remainder
|
||||
// as a count instead of silently dropping it.
|
||||
if listed > 0 && spent+len(line) > skillsContextListBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
spent += len(line)
|
||||
listed++
|
||||
}
|
||||
if omitted > 0 {
|
||||
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
|
||||
}
|
||||
b.WriteString("</available_skills>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
|
||||
}
|
||||
|
||||
func sessionRuntimeContext(options Options) string {
|
||||
provider := strings.TrimSpace(options.ProviderName)
|
||||
model := strings.TrimSpace(options.Model)
|
||||
@@ -310,9 +372,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
|
||||
// findProjectContextFile returns the first matching project guideline file
|
||||
// in dir. Lookup is case-insensitive on the basename and on the actual
|
||||
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
|
||||
// still resolves to its true filename on case-sensitive filesystems — the
|
||||
// returned path is what should appear in the project guidelines label.
|
||||
// filename returned, so a git-tracked file like AGENTS.md still resolves to
|
||||
// its true filename on case-sensitive filesystems — the returned path is what
|
||||
// should appear in the project guidelines label.
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
|
||||
@@ -46,14 +46,10 @@ work.
|
||||
formatting-only edits unless the user asked for them.
|
||||
4. **Verify.** Verify after edits; see the testing gate below. This is
|
||||
mandatory.
|
||||
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
|
||||
summary — never a single throwaway line. Use short headings and bullets: a
|
||||
one-line "what I built/changed"; **Where it lives** — the key files and how they
|
||||
fit together; **How each requirement is met** — walk through each requirement
|
||||
from the request and name the specifics that satisfy it; what validation ran and
|
||||
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
|
||||
the depth to the work — a big build earns several sections, a trivial fix earns
|
||||
a line or two.
|
||||
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
|
||||
line; a substantial change earns a few short bullets covering what changed, where
|
||||
it lives, and what you did and did not verify. Lead with the outcome, and never
|
||||
pad — do not restate the task or narrate steps the user already watched you take.
|
||||
|
||||
## Editing discipline
|
||||
|
||||
@@ -64,7 +60,9 @@ work.
|
||||
- Make one tool call per file. Do not batch multi-file writes into a single
|
||||
shell or script invocation.
|
||||
- For edits to existing files, prefer edit_file or apply_patch with minimal,
|
||||
targeted diffs. Match the existing indentation, imports, and idioms.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import "strings"
|
||||
//
|
||||
// Different model families respond to different framing: the GPT family benefits
|
||||
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance; the
|
||||
// Claude family is already well-aligned with the core prompt and only needs a
|
||||
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
|
||||
// block appended after the core prompt. Classification is by model id (the agent
|
||||
// only has the model string — no registry dependency).
|
||||
// Gemini benefits from explicit tool-preference and conciseness guidance. The
|
||||
// Claude family is already aligned with the core prompt and needs no addendum —
|
||||
// comment discipline is universal in the core prompt now, so every family gets it.
|
||||
// modelPromptAddendum returns the family-specific block appended after the core
|
||||
// prompt, or "" when the family is unknown or needs no specialization.
|
||||
// Classification is by model id (the agent only has the model string — no registry
|
||||
// dependency).
|
||||
|
||||
const (
|
||||
familyOpenAI = "openai"
|
||||
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
|
||||
return openAIPromptAddendum
|
||||
case familyGemini:
|
||||
return geminiPromptAddendum
|
||||
case familyAnthropic:
|
||||
return anthropicPromptAddendum
|
||||
default:
|
||||
// Anthropic (aligned with the core prompt) and unknown families get no
|
||||
// family-specific block; comment discipline now lives in the core prompt
|
||||
// for every model.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
|
||||
in one short clause why it is needed.
|
||||
- Use update_plan for any multi-step task and keep it current.
|
||||
</model_guidance>`
|
||||
|
||||
const anthropicPromptAddendum = `<model_guidance>
|
||||
- Do not add code comments unless the user asks or the surrounding code is
|
||||
comment-dense; match the file's existing comment density.
|
||||
</model_guidance>`
|
||||
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
|
||||
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
|
||||
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
|
||||
}
|
||||
// Claude is aligned with the core prompt and gets no family addendum now that
|
||||
// comment discipline is universal; it must not pick up another family's block.
|
||||
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
|
||||
if !strings.Contains(claude, anthropicPromptAddendum) {
|
||||
t.Fatalf("expected the Anthropic addendum in the claude prompt")
|
||||
if strings.Contains(claude, "<model_guidance>") {
|
||||
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
|
||||
}
|
||||
if strings.Contains(claude, openAIPromptAddendum) {
|
||||
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
|
||||
|
||||
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"how each requirement is met",
|
||||
"scaled to the work",
|
||||
"comment density",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
|
||||
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
|
||||
|
||||
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
|
||||
WhenToUse string
|
||||
}
|
||||
|
||||
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
|
||||
// frontmatter description) surfaced to the system prompt so the model can invoke
|
||||
// the right skill with the skill tool on the first try instead of guessing a name
|
||||
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
|
||||
// package needs no dependency on internal/skills.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
@@ -189,6 +199,12 @@ type Options struct {
|
||||
// populated only where the Task tool is actually registered, so an empty slice
|
||||
// (the default) reproduces the previous prompt byte-for-byte.
|
||||
Specialists []SpecialistInfo
|
||||
// Skills lists the reusable skills installed for this run (the default skills
|
||||
// dir merged with any plugin skill roots). When non-empty the system prompt
|
||||
// gains an <available_skills> block naming them so the model loads the right one
|
||||
// via the skill tool on the first try. Empty (the default) reproduces the
|
||||
// previous prompt byte-for-byte.
|
||||
Skills []SkillInfo
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
|
||||
+25
-4
@@ -80,6 +80,7 @@ type appDeps struct {
|
||||
runTUI func(context.Context, tui.Options) int
|
||||
runEditor func(string) error
|
||||
checkUpdate func(context.Context, update.Options) (update.Result, error)
|
||||
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -180,6 +181,7 @@ func defaultAppDeps() appDeps {
|
||||
runTUI: tui.Run,
|
||||
runEditor: openEditor,
|
||||
checkUpdate: update.Check,
|
||||
applyUpdate: update.Apply,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
@@ -383,6 +385,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
return runUpdate(args[1:], stdout, stderr, deps)
|
||||
case "upgrade":
|
||||
return runUpgrade(args[1:], stdout, stderr, deps)
|
||||
case "worktrees", "worktree":
|
||||
return runWorktrees(args[1:], stdout, stderr, deps)
|
||||
case "verify":
|
||||
@@ -515,6 +519,9 @@ func fillAppDeps(deps appDeps) appDeps {
|
||||
if deps.checkUpdate == nil {
|
||||
deps.checkUpdate = defaults.checkUpdate
|
||||
}
|
||||
if deps.applyUpdate == nil {
|
||||
deps.applyUpdate = defaults.applyUpdate
|
||||
}
|
||||
if deps.now == nil {
|
||||
deps.now = defaults.now
|
||||
}
|
||||
@@ -557,8 +564,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
|
||||
// or "providers ARE configured, just none marked active" (e.g. config.json's
|
||||
// activeProvider is blank/stale). In the second case resolved.Providers still
|
||||
// carries the already-normalized list — prefer falling back to one of those
|
||||
// over wiping everything and forcing the user to re-enter credentials they
|
||||
// already saved.
|
||||
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
|
||||
resolved.Provider = usable
|
||||
resolved.ActiveProvider = usable.Name
|
||||
} else {
|
||||
resolved = config.ResolvedConfig{}
|
||||
forceSetup = true
|
||||
}
|
||||
}
|
||||
userConfigPath, err := deps.userConfigPath()
|
||||
if err != nil {
|
||||
@@ -740,9 +758,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
Specialists: specialistRuntime.specialists,
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Notify: resolved.Notify,
|
||||
KeyBindings: resolved.KeyBindings,
|
||||
Setup: tui.SetupOptions{
|
||||
Visible: setupVisible,
|
||||
Required: needsSetup,
|
||||
@@ -1158,7 +1178,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
|
||||
}
|
||||
|
||||
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
|
||||
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
|
||||
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
|
||||
// provider like Ollama/LM Studio that needs no API key.
|
||||
func baseURLIsLoopback(baseURL string) bool {
|
||||
u := strings.TrimSpace(baseURL)
|
||||
if u == "" {
|
||||
@@ -1173,7 +1194,7 @@ func baseURLIsLoopback(baseURL string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
launched := false
|
||||
var providerProfile config.ProviderProfile
|
||||
fake := &cliFakeProvider{}
|
||||
|
||||
usable := config.ProviderProfile{
|
||||
Name: "work",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: "gpt-test",
|
||||
}
|
||||
|
||||
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) {
|
||||
return cwd, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
return fake, nil
|
||||
},
|
||||
userConfigPath: func() (string, error) {
|
||||
return userConfigPath, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
},
|
||||
runTUI: func(ctx context.Context, options tui.Options) int {
|
||||
launched = true
|
||||
launchedOptions = options
|
||||
return 0
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
||||
}
|
||||
if !launched {
|
||||
t.Fatal("TUI was not launched")
|
||||
}
|
||||
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
||||
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
||||
applyResult := update.ApplyResult{
|
||||
Result: update.Result{
|
||||
CurrentVersion: "dev",
|
||||
LatestVersion: "0.2.0",
|
||||
UpdateAvailable: true,
|
||||
},
|
||||
Applied: true,
|
||||
InstallMethod: update.InstallMethodStandalone,
|
||||
BinaryPath: "/usr/local/bin/zero",
|
||||
Message: "updated to 0.2.0",
|
||||
}
|
||||
var gotOptions update.Options
|
||||
deps := appDeps{
|
||||
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
||||
gotOptions = options
|
||||
return applyResult, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for --apply")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
||||
t.Fatalf("unexpected apply text: %q", stdout.String())
|
||||
}
|
||||
if gotOptions.CurrentVersion != "dev" {
|
||||
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
InstallMethod string `json:"installMethod"`
|
||||
BinaryPath string `json:"binaryPath"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
||||
t.Fatalf("unexpected apply JSON: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
||||
var applyCalled bool
|
||||
deps := appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
applyCalled = true
|
||||
return update.ApplyResult{Message: "already up to date"}, nil
|
||||
},
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !applyCalled {
|
||||
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
||||
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
||||
t.Fatal("checkUpdate should not run")
|
||||
return update.Result{}, nil
|
||||
},
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
||||
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"update", "--apply", "--target", "linux-arm64"},
|
||||
{"upgrade", "--target", "linux-arm64"},
|
||||
} {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
||||
return update.ApplyResult{}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
||||
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUpdateReportsApplyError(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
||||
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
||||
return update.ApplyResult{}, errors.New("download failed")
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode == exitSuccess {
|
||||
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
||||
t.Fatalf("expected apply error, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHelpOutput(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+19
-3
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
|
||||
// auth/callback) and mandatory authorize params (id_token_add_organizations,
|
||||
// codex_cli_simplified_flow, originator) that the generic loopback flow
|
||||
// cannot supply. Route it to the dedicated ChatGPT login so
|
||||
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
|
||||
if strings.EqualFold(provider, "chatgpt") {
|
||||
if parsed.device {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
|
||||
}
|
||||
if len(parsed.scopes) > 0 {
|
||||
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
|
||||
}
|
||||
return runAuthChatGPT(nil, stdout, stderr, deps)
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Provider: provider,
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
@@ -489,8 +504,9 @@ Commands:
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
|
||||
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
|
||||
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
|
||||
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
|
||||
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
|
||||
// authorize params), not the generic manager path. The generic login accepts
|
||||
// --device, so a ChatGPT-specific rejection proves the routing took effect.
|
||||
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
|
||||
// without the required extra params, so OpenAI's authorize endpoint rejected it.
|
||||
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
|
||||
}
|
||||
// Case-insensitive provider name should still route.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login ChatGPT --device should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
|
||||
// must not be silently dropped on the ChatGPT path. The Codex client
|
||||
// registration pins a fixed scope set (incl. api.connectors.*), so custom
|
||||
// scopes are rejected up front rather than plumbed through.
|
||||
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("auth login chatgpt --scope should be rejected")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
|
||||
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -508,6 +508,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
Skills: pluginActivation.skillInfos(deps.skillsDir()),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
|
||||
@@ -3,7 +3,9 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/plugins"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
|
||||
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
|
||||
}
|
||||
|
||||
// skillInfos resolves the reusable skills the model can load via the skill tool —
|
||||
// the default skills dir merged with any plugin skill roots, the same set the
|
||||
// plugin-aware skill tool resolves against — as plain data for the agent's system
|
||||
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
|
||||
// the prompt byte-identical.
|
||||
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
|
||||
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
infos := make([]agent.SkillInfo, 0, len(merged))
|
||||
for _, skill := range merged {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
|
||||
// line, prefixing the diagnostic kind and appending the most specific locator
|
||||
// available (manifest path, then plugin path, then plugin ID) so an operator can
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}{
|
||||
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
@@ -46,6 +47,21 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "longcat")
|
||||
|
||||
for _, want := range []string{
|
||||
"requiresAuth=true",
|
||||
"authEnvVars=LONGCAT_API_KEY",
|
||||
"setup: zero providers setup longcat --set-active",
|
||||
} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Fatalf("expected LongCat catalog block to contain %q, got:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderCatalogOllamaCloudRequiresAPIKey(t *testing.T) {
|
||||
output := runProviderCatalogOnboarding(t)
|
||||
block := providerCatalogOnboardingBlock(t, output, "ollama-cloud")
|
||||
|
||||
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
}
|
||||
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
|
||||
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
|
||||
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+51
-8
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type updateOptions struct {
|
||||
check bool
|
||||
apply bool
|
||||
json bool
|
||||
repository string
|
||||
endpoint string
|
||||
@@ -20,6 +21,18 @@ type updateOptions struct {
|
||||
}
|
||||
|
||||
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, false)
|
||||
}
|
||||
|
||||
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
return runUpdateCommand(args, stdout, stderr, deps, true)
|
||||
}
|
||||
|
||||
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
|
||||
// makes `--apply` the implicit behavior for `zero upgrade` when neither
|
||||
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
|
||||
// one of the two so existing scripts around `zero update --check` don't change.
|
||||
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
|
||||
options, help, err := parseUpdateArgs(args)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if !options.check {
|
||||
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
|
||||
if options.check && options.apply {
|
||||
return writeUsageError(stderr, "Pass only one of --check or --apply.")
|
||||
}
|
||||
checkOptions := update.Options{
|
||||
if !options.check && !options.apply {
|
||||
if !defaultApply {
|
||||
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
|
||||
}
|
||||
options.apply = true
|
||||
}
|
||||
if options.apply && options.target != "" {
|
||||
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
|
||||
}
|
||||
updateOptions := update.Options{
|
||||
CurrentVersion: version,
|
||||
Repository: options.repository,
|
||||
Endpoint: options.endpoint,
|
||||
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
if err != nil {
|
||||
return writeUsageError(stderr, err.Error())
|
||||
}
|
||||
checkOptions.GOOS = target.GOOS
|
||||
checkOptions.GOARCH = target.GOARCH
|
||||
updateOptions.GOOS = target.GOOS
|
||||
updateOptions.GOARCH = target.GOARCH
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), checkOptions)
|
||||
if options.apply {
|
||||
result, err := deps.applyUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, result); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
result, err := deps.checkUpdate(context.Background(), updateOptions)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
|
||||
}
|
||||
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
|
||||
return options, true, nil
|
||||
case arg == "--check":
|
||||
options.check = true
|
||||
case arg == "--apply":
|
||||
options.apply = true
|
||||
case arg == "--json":
|
||||
options.json = true
|
||||
case arg == "--repo":
|
||||
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
|
||||
func writeUpdateHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero update --check [flags]
|
||||
zero update --apply [flags]
|
||||
zero upgrade [flags]
|
||||
|
||||
Flags:
|
||||
--check Check the latest GitHub release without installing
|
||||
--json Print the update check result as JSON
|
||||
--apply Download, verify, and install the latest release
|
||||
--json Print the update result as JSON
|
||||
--repo <owner/repo> Repository to check when no endpoint is provided
|
||||
--endpoint <url|repo> Release API URL or owner/repo slug to check
|
||||
--timeout <duration> Release check timeout (default 5s)
|
||||
--target <platform> Release target to verify (for example windows-x64)
|
||||
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
|
||||
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
|
||||
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChangesArgsAuto(t *testing.T) {
|
||||
// 1. Correct parsing of auto
|
||||
for _, args := range [][]string{
|
||||
{"--auto"},
|
||||
{"-a"},
|
||||
} {
|
||||
options, help, err := parseChangesArgs(args, "commit")
|
||||
if err != nil {
|
||||
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
|
||||
}
|
||||
if help {
|
||||
t.Fatalf("parseChangesArgs(%v) returned help", args)
|
||||
}
|
||||
if !options.auto {
|
||||
t.Fatalf("auto = false, want true (args %v)", args)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Reject --auto on inspect
|
||||
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
|
||||
if err == nil || !strings.Contains(err.Error(), "--auto") {
|
||||
t.Fatalf("expected --auto rejection on inspect, got %v", err)
|
||||
}
|
||||
|
||||
// 3. Reject both --message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
|
||||
// 4. Reject both empty message and --auto on commit
|
||||
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
|
||||
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type mockCommitMsgProvider struct {
|
||||
response string
|
||||
req zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.req = request
|
||||
events := make(chan zeroruntime.StreamEvent, 2)
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
|
||||
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(events)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestRunChangesCommitAuto(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
summary := zerogit.ChangeSummary{
|
||||
Root: cwd,
|
||||
Branch: "main",
|
||||
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
|
||||
Diff: "some diff content with ghp_SECRETKEYHERE",
|
||||
}
|
||||
|
||||
mockProv := &mockCommitMsgProvider{
|
||||
response: "```\nfeat: auto commit message\n```",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
commitCalled := false
|
||||
|
||||
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
cfg := execResolvedConfig()
|
||||
cfg.Provider.Name = "openai"
|
||||
cfg.Provider.Model = "gpt-4o"
|
||||
return cfg, nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProv, nil
|
||||
},
|
||||
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
|
||||
commitCalled = true
|
||||
if options.Message != "feat: auto commit message" {
|
||||
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
|
||||
}
|
||||
return zerogit.CommitResult{
|
||||
Root: cwd,
|
||||
Message: options.Message,
|
||||
Committed: true,
|
||||
CommitHash: "abc1234",
|
||||
Before: summary,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
if !commitCalled {
|
||||
t.Fatal("expected commitChanges to be called")
|
||||
}
|
||||
// Verify that secret in the diff was redacted
|
||||
promptContent := mockProv.req.Messages[0].Content
|
||||
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
|
||||
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
|
||||
}
|
||||
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
|
||||
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
|
||||
t.Fatalf("expected status message in stdout, got %q", stdout.String())
|
||||
}
|
||||
|
||||
t.Run("EmptyLLMResponse", func(t *testing.T) {
|
||||
mockProvEmpty := &mockCommitMsgProvider{
|
||||
response: " \n\n ",
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
|
||||
return summary, nil
|
||||
},
|
||||
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return mockProvEmpty, nil
|
||||
},
|
||||
})
|
||||
if code == exitSuccess {
|
||||
t.Fatal("expected command to fail when LLM returns empty response, got success")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "empty commit message") {
|
||||
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/selfverify"
|
||||
"github.com/Gitlawb/zero/internal/testrunner"
|
||||
"github.com/Gitlawb/zero/internal/verify"
|
||||
"github.com/Gitlawb/zero/internal/worktrees"
|
||||
"github.com/Gitlawb/zero/internal/zerogit"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type worktreeCommandOptions struct {
|
||||
@@ -35,8 +38,10 @@ type changesCommandOptions struct {
|
||||
cwd string
|
||||
baseRef string
|
||||
message string
|
||||
hasMessage bool
|
||||
dryRun bool
|
||||
maxDiffBytes int
|
||||
auto bool
|
||||
}
|
||||
|
||||
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
@@ -198,9 +203,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
|
||||
}
|
||||
return exitSuccess
|
||||
case "commit":
|
||||
var message string
|
||||
if options.auto {
|
||||
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
|
||||
Cwd: workspaceRoot,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
|
||||
}
|
||||
if summary.Clean {
|
||||
return writeExecUsageError(stderr, "no changes to commit")
|
||||
}
|
||||
|
||||
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
|
||||
}
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
|
||||
}
|
||||
provider, err := deps.newProvider(resolved.Provider)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
|
||||
}
|
||||
|
||||
if !options.json {
|
||||
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
}
|
||||
|
||||
safeSummary := redactChangeSummary(summary)
|
||||
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
|
||||
}
|
||||
message = msg
|
||||
} else {
|
||||
message = options.message
|
||||
}
|
||||
|
||||
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
|
||||
Cwd: workspaceRoot,
|
||||
Message: options.message,
|
||||
Message: message,
|
||||
DryRun: options.dryRun,
|
||||
MaxDiffBytes: options.maxDiffBytes,
|
||||
})
|
||||
@@ -395,9 +443,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.message = value
|
||||
options.hasMessage = true
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--message="):
|
||||
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
|
||||
options.hasMessage = true
|
||||
case arg == "--diff-bytes":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
@@ -415,14 +465,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
|
||||
return options, false, err
|
||||
}
|
||||
options.maxDiffBytes = maxDiffBytes
|
||||
case arg == "-a" || arg == "--auto":
|
||||
options.auto = true
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
|
||||
}
|
||||
}
|
||||
if command != "commit" && (options.message != "" || options.dryRun) {
|
||||
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
|
||||
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
|
||||
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
|
||||
}
|
||||
if command == "commit" && options.hasMessage && options.auto {
|
||||
return options, false, execUsageError{"cannot specify both --message and --auto"}
|
||||
}
|
||||
if command == "commit" && options.baseRef != "" {
|
||||
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
|
||||
@@ -712,9 +767,47 @@ Flags:
|
||||
--base <ref> Diff against <ref>...HEAD instead of the working tree
|
||||
--diff-bytes <n> Maximum diff bytes to include
|
||||
-m, --message <text> Commit message for `+"`zero changes commit`"+`
|
||||
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
|
||||
--dry-run Preview commit metadata without mutating git state
|
||||
--json Print JSON output
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
|
||||
var promptBuilder strings.Builder
|
||||
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
|
||||
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
|
||||
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
|
||||
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
|
||||
promptBuilder.WriteString("Git Diff:\n")
|
||||
promptBuilder.WriteString(summary.Diff)
|
||||
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
|
||||
},
|
||||
}
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
collected := zeroruntime.CollectStream(ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("%s", collected.Error)
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(collected.Text)
|
||||
if strings.HasPrefix(msg, "```") {
|
||||
if idx := strings.Index(msg, "\n"); idx != -1 {
|
||||
msg = msg[idx+1:]
|
||||
}
|
||||
}
|
||||
msg = strings.TrimSuffix(msg, "```")
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
return "", fmt.Errorf("provider returned empty commit message")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
|
||||
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
// On ErrNoActiveProvider, providers may still hold the successfully
|
||||
// normalized (but active-less) profile list — keep it so a caller can fall
|
||||
// back to an already-configured usable provider instead of treating this
|
||||
// like a config with nothing set up at all.
|
||||
return ResolvedConfig{Providers: providers}, err
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
LocalControl: cfg.LocalControl,
|
||||
}, nil
|
||||
}
|
||||
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
|
||||
}
|
||||
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -277,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if src.Swarm.MaxTeamSize != 0 {
|
||||
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
|
||||
}
|
||||
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
|
||||
// Local control is intentionally user-config/override only. A cloned project
|
||||
// must not be able to make browser, desktop, or terminal automation tools
|
||||
// appear in the model's tool surface.
|
||||
@@ -665,6 +672,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
cfg.Tools.deferThresholdSet = true
|
||||
}
|
||||
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
|
||||
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
@@ -700,6 +708,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
|
||||
}
|
||||
}
|
||||
|
||||
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
|
||||
if src.ToggleDetailed != "" {
|
||||
dst.ToggleDetailed = src.ToggleDetailed
|
||||
}
|
||||
if src.ToggleMouse != "" {
|
||||
dst.ToggleMouse = src.ToggleMouse
|
||||
}
|
||||
if src.CycleReasoning != "" {
|
||||
dst.CycleReasoning = src.CycleReasoning
|
||||
}
|
||||
if src.TogglePlan != "" {
|
||||
dst.TogglePlan = src.TogglePlan
|
||||
}
|
||||
if src.ToggleSidebar != "" {
|
||||
dst.ToggleSidebar = src.ToggleSidebar
|
||||
}
|
||||
}
|
||||
|
||||
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
|
||||
if len(src.Servers) == 0 {
|
||||
return
|
||||
@@ -834,7 +860,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
|
||||
}
|
||||
|
||||
if !activeFound {
|
||||
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
// Return the successfully normalized list alongside the error (rather than
|
||||
// nil) so a caller like the interactive TUI can still fall back to an
|
||||
// already-configured, usable provider instead of forcing a full
|
||||
// re-onboarding wizard just because none was marked active.
|
||||
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
|
||||
}
|
||||
if active.Model == "" {
|
||||
return nil, ProviderProfile{}, &setupFixableError{
|
||||
|
||||
@@ -916,6 +916,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// Multiple providers configured (e.g. via `zero provider add`) but
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
|
||||
}
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
@@ -1059,6 +1078,130 @@ func TestResolveAppliesProviderCatalogDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesMiniMaxCNCatalogDefaults(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "mini-cn",
|
||||
"providers": [{
|
||||
"name": "mini-cn",
|
||||
"catalog_id": "minimaxi-cn"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"MINIMAXI_API_KEY": "sk-cn-mini",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "minimaxi-cn" {
|
||||
t.Fatalf("CatalogID = %q, want minimaxi-cn", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindAnthropicCompat)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://api.minimaxi.com/anthropic" {
|
||||
t.Fatalf("BaseURL = %q, want MiniMax CN default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "MiniMax-M3" {
|
||||
t.Fatalf("Model = %q, want MiniMax-M3", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "MINIMAXI_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want MINIMAXI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-cn-mini" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesZaiCNCatalogDefaults(t *testing.T) {
|
||||
// The "zai-cn" catalog entry preserves the legacy open.bigmodel.cn endpoint
|
||||
// for users who still target the China hosting; "zai" itself now points at
|
||||
// api.z.ai (international). The two env-vars are intentionally distinct
|
||||
// (ZHIPU_API_KEY for China, ZAI_API_KEY for international) so an account on
|
||||
// one side cannot be silently used against the other.
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "zai-cn",
|
||||
"providers": [{
|
||||
"name": "zai-cn",
|
||||
"catalog_id": "zai-cn"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"ZHIPU_API_KEY": "sk-zhipu-cn",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "zai-cn" {
|
||||
t.Fatalf("CatalogID = %q, want zai-cn", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://open.bigmodel.cn/api/paas/v4" {
|
||||
t.Fatalf("BaseURL = %q, want Z.ai China default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "glm-4.5" {
|
||||
t.Fatalf("Model = %q, want glm-4.5 (China default)", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "ZHIPU_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want ZHIPU_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-zhipu-cn" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAppliesZaiInternationalCatalogDefaults(t *testing.T) {
|
||||
// "zai" now resolves to the international endpoint; the catalog default
|
||||
// model stays glm-4.5 (live discovery upgrades to glm-5.2 at runtime).
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "zai-intl",
|
||||
"providers": [{
|
||||
"name": "zai-intl",
|
||||
"catalog_id": "zai"
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{
|
||||
"ZAI_API_KEY": "sk-zai-intl",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if resolved.Provider.CatalogID != "zai" {
|
||||
t.Fatalf("CatalogID = %q, want zai", resolved.Provider.CatalogID)
|
||||
}
|
||||
if resolved.Provider.ProviderKind != ProviderKindOpenAICompatible {
|
||||
t.Fatalf("ProviderKind = %q, want %q", resolved.Provider.ProviderKind, ProviderKindOpenAICompatible)
|
||||
}
|
||||
if resolved.Provider.BaseURL != "https://api.z.ai/api/paas/v4" {
|
||||
t.Fatalf("BaseURL = %q, want Z.ai international default", resolved.Provider.BaseURL)
|
||||
}
|
||||
if resolved.Provider.Model != "glm-4.5" {
|
||||
t.Fatalf("Model = %q, want glm-4.5 (preserved default; live discovery upgrades to glm-5.2 at runtime)", resolved.Provider.Model)
|
||||
}
|
||||
if resolved.Provider.APIKeyEnv != "ZAI_API_KEY" {
|
||||
t.Fatalf("APIKeyEnv = %q, want ZAI_API_KEY", resolved.Provider.APIKeyEnv)
|
||||
}
|
||||
if resolved.Provider.APIKey != "sk-zai-intl" {
|
||||
t.Fatalf("APIKey = %q, want env-resolved secret", resolved.Provider.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCatalogAliasAndLocalNoAuth(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "local",
|
||||
|
||||
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
|
||||
return p.Recaps == nil || *p.Recaps
|
||||
}
|
||||
|
||||
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
|
||||
// can remap. An empty string means "use the built-in default" for that action.
|
||||
type KeyBindingDef string
|
||||
|
||||
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
|
||||
// via config.json. Each field defaults to a sensible built-in when empty.
|
||||
type KeyBindingsConfig struct {
|
||||
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
|
||||
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
|
||||
// ToggleMouse toggles mouse capture release (default: ctrl+e).
|
||||
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
|
||||
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
|
||||
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
|
||||
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
|
||||
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
|
||||
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
|
||||
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
|
||||
}
|
||||
|
||||
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
|
||||
// Helpers are discovered lazily by the tool that needs them; no setup command or
|
||||
// background probe is required during startup.
|
||||
@@ -206,6 +225,7 @@ type FileConfig struct {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
|
||||
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools ToolsConfig `json:"tools,omitempty"`
|
||||
Swarm SwarmConfig `json:"swarm,omitempty"`
|
||||
Preferences PreferencesConfig `json:"preferences,omitempty"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
|
||||
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Tools: cfg.Tools,
|
||||
Swarm: cfg.Swarm,
|
||||
Preferences: cfg.Preferences,
|
||||
KeyBindings: cfg.KeyBindings,
|
||||
}
|
||||
if !cfg.LocalControl.Empty() {
|
||||
raw.LocalControl = &cfg.LocalControl
|
||||
@@ -256,6 +278,7 @@ type Overrides struct {
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
|
||||
Tools ToolsConfig
|
||||
Swarm SwarmConfig
|
||||
Preferences PreferencesConfig
|
||||
KeyBindings KeyBindingsConfig
|
||||
LocalControl LocalControlConfig
|
||||
}
|
||||
|
||||
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Swarm SwarmConfig `json:"swarm"`
|
||||
Preferences PreferencesConfig `json:"preferences"`
|
||||
KeyBindings KeyBindingsConfig `json:"keybindings"`
|
||||
LocalControl LocalControlConfig `json:"localControl"`
|
||||
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
|
||||
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
|
||||
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
cfg.Tools = raw.Tools
|
||||
cfg.Swarm = raw.Swarm
|
||||
cfg.Preferences = raw.Preferences
|
||||
cfg.KeyBindings = raw.KeyBindings
|
||||
cfg.LocalControl = raw.LocalControl
|
||||
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
|
||||
cfg.MCP.Servers = map[string]MCPServerConfig{}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -211,6 +212,85 @@ func TestHTTPClientListsAndCallsTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientFollowsSameOriginRedirect(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
testServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/mcp" {
|
||||
http.Redirect(response, request, "/redirected", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/redirected" {
|
||||
t.Errorf("request = %s %s, want POST /redirected", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
message := readHTTPRPCMessage(t, request)
|
||||
switch message.Method {
|
||||
case "initialize":
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{"protocolVersion": "2024-11-05"})
|
||||
case "notifications/initialized":
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
writeHTTPRPCResponse(t, response, message.ID, map[string]any{})
|
||||
}
|
||||
}))
|
||||
defer testServer.Close()
|
||||
|
||||
client, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: testServer.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientRejectsCrossOriginRedirectBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("redirect target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(response, request, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeHTTP,
|
||||
URL: redirector.URL + "/mcp",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -340,6 +420,53 @@ func TestSSEClientListsAndCallsToolsFromRemoteStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEClientRejectsCrossOriginEndpointBeforeSendingHeaders(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := request.Header.Get("X-Api-Key"); got != "" {
|
||||
t.Errorf("SSE endpoint target received X-Api-Key = %q", got)
|
||||
}
|
||||
response.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
stream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if got := request.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q, want secret on configured SSE server", got)
|
||||
http.Error(response, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodGet || request.URL.Path != "/sse" {
|
||||
t.Errorf("request = %s %s, want GET /sse", request.Method, request.URL.Path)
|
||||
http.Error(response, "bad request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprintf(response, "event: endpoint\ndata: %s/messages\n\n", target.URL)
|
||||
if flusher, ok := response.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer stream.Close()
|
||||
|
||||
_, err := Connect(ctx, Server{
|
||||
Name: "docs",
|
||||
Type: ServerTypeSSE,
|
||||
URL: stream.URL + "/sse",
|
||||
Headers: map[string]string{"X-Api-Key": "secret"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "endpoint origin") {
|
||||
t.Fatalf("Connect() error = %v, want cross-origin endpoint error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("SSE endpoint target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientReportsNonOKStatus(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -733,7 +733,11 @@ func resolveSSEEndpointURL(baseValue string, endpointValue string) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse MCP SSE endpoint URL: %w", err)
|
||||
}
|
||||
return baseURL.ResolveReference(endpointURL).String(), nil
|
||||
resolvedURL := baseURL.ResolveReference(endpointURL)
|
||||
if !sameMCPOrigin(baseURL, resolvedURL) {
|
||||
return "", fmt.Errorf("MCP SSE endpoint origin %s differs from configured server origin %s", mcpOrigin(resolvedURL), mcpOrigin(baseURL))
|
||||
}
|
||||
return resolvedURL.String(), nil
|
||||
}
|
||||
|
||||
func rpcResponseKey(id any) string {
|
||||
@@ -873,10 +877,10 @@ func (source *storeTokenSource) Refresh(ctx context.Context) (string, error) {
|
||||
|
||||
// oauthHTTPClient returns an HTTP client whose transport attaches OAuth bearer
|
||||
// tokens and refreshes them on 401 for OAuth-configured servers. Servers that do
|
||||
// not declare OAuth get the default client unchanged.
|
||||
// not declare OAuth use the default transport with the same MCP redirect guard.
|
||||
func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
if !strings.EqualFold(strings.TrimSpace(server.Auth), ServerAuthOAuth) {
|
||||
return http.DefaultClient, nil
|
||||
return mcpHTTPClient(server, nil), nil
|
||||
}
|
||||
store, err := NewTokenStore(TokenStoreOptions{})
|
||||
if err != nil {
|
||||
@@ -888,5 +892,5 @@ func oauthHTTPClient(server Server) (*http.Client, error) {
|
||||
httpClient: http.DefaultClient,
|
||||
now: time.Now,
|
||||
}
|
||||
return &http.Client{Transport: newOAuthRoundTripper(http.DefaultTransport, source, server.Name)}, nil
|
||||
return mcpHTTPClient(server, newOAuthRoundTripper(http.DefaultTransport, source, server.Name)), nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -149,6 +150,141 @@ func TestOAuthRoundTripperDoesNotRetryAfterSuccessfulNon401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthRoundTripperRejectsCrossOriginRedirectBeforeBearerLeak(t *testing.T) {
|
||||
source := &fakeTokenSource{}
|
||||
source.access.Store("access-secret")
|
||||
source.refreshFunc = func() (string, error) { return "access-fresh", nil }
|
||||
|
||||
var targetHits int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&targetHits, 1)
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("redirect target received Authorization = %q", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/mcp", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
client := mcpHTTPClient(
|
||||
Server{Name: "oauth-docs", Type: ServerTypeHTTP},
|
||||
newOAuthRoundTripper(http.DefaultTransport, source, "oauth-docs"),
|
||||
)
|
||||
resp, err := client.Get(redirector.URL + "/mcp")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Get() error = %v, want cross-origin redirect error", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&targetHits); got != 0 {
|
||||
t.Fatalf("redirect target hits = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameMCPOriginNormalizesDefaultPorts(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "https default port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://EXAMPLE.test:443/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "http default port",
|
||||
left: "http://example.test/mcp",
|
||||
right: "http://example.test:80/other",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different scheme",
|
||||
left: "https://example.test/mcp",
|
||||
right: "http://example.test:443/other",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different port",
|
||||
left: "https://example.test/mcp",
|
||||
right: "https://example.test:8443/other",
|
||||
want: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
left, err := url.Parse(tc.left)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse(tc.right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sameMCPOrigin(left, right); got != tc.want {
|
||||
t.Fatalf("sameMCPOrigin(%q, %q) = %v, want %v", tc.left, tc.right, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSSEEndpointURLRestrictsOrigin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
base string
|
||||
endpoint string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "relative endpoint",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "absolute same origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test/messages",
|
||||
want: "https://example.test/messages",
|
||||
},
|
||||
{
|
||||
name: "default port equivalent",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://example.test:443/messages",
|
||||
want: "https://example.test:443/messages",
|
||||
},
|
||||
{
|
||||
name: "cross origin",
|
||||
base: "https://example.test/sse",
|
||||
endpoint: "https://evil.test/messages",
|
||||
wantErr: "differs from configured server origin",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := resolveSSEEndpointURL(tc.base, tc.endpoint)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSSEEndpointURL() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveSSEEndpointURL() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOAuthServerIsUnaffected(t *testing.T) {
|
||||
// Regression: a server without auth must not get a bearer header or any
|
||||
// OAuth round tripper, and must connect normally.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxMCPRedirects = 10
|
||||
|
||||
func mcpHTTPClient(server Server, transport http.RoundTripper) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: checkMCPRedirect(server),
|
||||
}
|
||||
}
|
||||
|
||||
func checkMCPRedirect(server Server) func(*http.Request, []*http.Request) error {
|
||||
return func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxMCPRedirects {
|
||||
return fmt.Errorf("MCP %s server %s stopped after %d redirects", server.Type, server.Name, maxMCPRedirects)
|
||||
}
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !sameMCPOrigin(via[0].URL, request.URL) {
|
||||
return fmt.Errorf("MCP %s server %s refused cross-origin redirect to %s", server.Type, server.Name, mcpOrigin(request.URL))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func sameMCPOrigin(left *url.URL, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectiveMCPPort(left) == effectiveMCPPort(right)
|
||||
}
|
||||
|
||||
func effectiveMCPPort(value *url.URL) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
if port := value.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(value.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mcpOrigin(value *url.URL) string {
|
||||
if value == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
host := value.Hostname()
|
||||
port := effectiveMCPPort(value)
|
||||
if port != "" {
|
||||
host = net.JoinHostPort(host, port)
|
||||
}
|
||||
if value.Scheme == "" {
|
||||
return host
|
||||
}
|
||||
return strings.ToLower(value.Scheme) + "://" + host
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package modelregistry
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
@@ -42,8 +43,20 @@ type ListOptions struct {
|
||||
Capability ModelCapability
|
||||
}
|
||||
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
// defaultRegistry builds the default registry exactly once and caches the result.
|
||||
// The default catalog is static, every accessor returns clones, and a built
|
||||
// Registry is never mutated (concurrent reads of its maps are safe), so a single
|
||||
// shared instance is correct — and it avoids re-cloning all entries and
|
||||
// recompiling every fuzzy-match regex on the per-frame TUI paths (title chip,
|
||||
// context gauge, pickers) that call DefaultRegistry each render.
|
||||
var defaultRegistry = sync.OnceValues(func() (Registry, error) {
|
||||
return NewRegistry(DefaultModelEntries())
|
||||
})
|
||||
|
||||
// DefaultRegistry returns the shared, immutable default model registry, building
|
||||
// it on first use.
|
||||
func DefaultRegistry() (Registry, error) {
|
||||
return defaultRegistry()
|
||||
}
|
||||
|
||||
func DefaultModelEntries() []ModelEntry {
|
||||
@@ -76,11 +89,13 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
patterns []string
|
||||
deprecation *DeprecationRule
|
||||
upgradeTarget string
|
||||
fastVariant string
|
||||
}{
|
||||
"claude-sonnet-4.5": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
|
||||
upgradeTarget: "claude-opus-4.1",
|
||||
fastVariant: "claude-haiku-4.5",
|
||||
},
|
||||
"claude-opus-4.1": {
|
||||
defaultEffort: ReasoningEffortHigh,
|
||||
@@ -94,6 +109,7 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
"gemini-2.5-pro": {
|
||||
defaultEffort: ReasoningEffortMedium,
|
||||
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
|
||||
fastVariant: "gemini-2.5-flash",
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
defaultEffort: ReasoningEffortLow,
|
||||
@@ -102,6 +118,12 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
"gemini-2.5-flash-lite": {
|
||||
upgradeTarget: "gemini-2.5-flash",
|
||||
},
|
||||
"gpt-4.1": {
|
||||
fastVariant: "gpt-4.1-mini",
|
||||
},
|
||||
"gpt-4o": {
|
||||
fastVariant: "gpt-4o-mini",
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
upgradeTarget: "gpt-4.1",
|
||||
},
|
||||
@@ -143,6 +165,9 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
if extra.upgradeTarget != "" {
|
||||
entries[index].UpgradeTargetID = extra.upgradeTarget
|
||||
}
|
||||
if extra.fastVariant != "" {
|
||||
entries[index].FastVariantID = extra.fastVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,14 @@ type ModelEntry struct {
|
||||
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
|
||||
// models). Resolved and availability-checked by Registry.UpgradeTarget.
|
||||
UpgradeTargetID string
|
||||
Description string
|
||||
// FastVariantID, when set, names a genuinely faster same-family model this one
|
||||
// can drop down to (e.g. claude-sonnet-4.5 -> claude-haiku-4.5). Empty means no
|
||||
// fast variant. It is the single source of truth for the /fast command and the
|
||||
// "f" fast-mode marker: Registry.FastVariant walks it forward and BaseVariant
|
||||
// walks it in reverse, so a model is "in fast mode" iff another model names it
|
||||
// here. Resolved and availability-checked like UpgradeTargetID.
|
||||
FastVariantID string
|
||||
Description string
|
||||
}
|
||||
|
||||
// DeprecationRule describes how a deprecated model is phased out and what to use
|
||||
@@ -368,6 +375,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
|
||||
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
|
||||
}
|
||||
}
|
||||
// A non-empty FastVariantID must resolve to a known model, mirroring the
|
||||
// UpgradeTargetID check — otherwise /fast and the "f" fast-mode marker would
|
||||
// silently vanish at runtime on a catalog typo instead of failing loudly here.
|
||||
for _, entry := range registry.models {
|
||||
fastID := strings.TrimSpace(entry.FastVariantID)
|
||||
if fastID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := registry.Get(fastID); !ok {
|
||||
return Registry{}, fmt.Errorf("model %q fast variant %q does not resolve to a known model", entry.ID, fastID)
|
||||
}
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package modelregistry
|
||||
|
||||
import "strings"
|
||||
|
||||
// The base<->fast model mapping is data, not logic: it lives entirely in each
|
||||
// ModelEntry's FastVariantID field (populated in the catalog, validated in
|
||||
// NewRegistry). Adding a pair is a one-line catalog change — never a code change
|
||||
// here. Both directions derive from that one field: FastVariant walks a model to
|
||||
// the fast id it names, BaseVariant scans for the model that names id as its fast
|
||||
// variant. This is why "fast mode" is encoded by model identity and needs no flag.
|
||||
//
|
||||
// Only models with an unambiguous 1:1 fast counterpart carry a FastVariantID. A
|
||||
// model with no clean counterpart (e.g. a top-tier Opus, where both Sonnet and
|
||||
// Haiku would be candidates) is intentionally left unset, so /fast reports "no
|
||||
// fast mode available" for it rather than guessing.
|
||||
|
||||
// FastVariant resolves id to its configured faster same-family counterpart. It
|
||||
// resolves id to a concrete entry, reads that entry's FastVariantID, and returns
|
||||
// the target only when it resolves and is not deprecated. Returns (_, false) when
|
||||
// id is unknown, has no fast variant, or the variant is unavailable/deprecated.
|
||||
// Mirrors Registry.UpgradeTarget.
|
||||
func (registry Registry) FastVariant(id string) (ModelEntry, bool) {
|
||||
source, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
fastID := strings.TrimSpace(source.FastVariantID)
|
||||
if fastID == "" {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
target, ok := registry.Resolve(fastID)
|
||||
if !ok || target.Status == ModelStatusDeprecated {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
// BaseVariant resolves id to the base model that declares id as its fast variant
|
||||
// — the reverse of FastVariant. A non-empty result therefore means id IS a fast
|
||||
// model: this is exactly how the /fast command derives "currently in fast mode"
|
||||
// without storing any flag. Same resolution and availability rules as FastVariant.
|
||||
func (registry Registry) BaseVariant(id string) (ModelEntry, bool) {
|
||||
source, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
for _, entry := range registry.models {
|
||||
fastID := strings.TrimSpace(entry.FastVariantID)
|
||||
if fastID == "" {
|
||||
continue
|
||||
}
|
||||
target, ok := registry.Resolve(fastID)
|
||||
if !ok || target.ID != source.ID {
|
||||
continue
|
||||
}
|
||||
if entry.Status == ModelStatusDeprecated {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
base, ok := registry.Resolve(entry.ID)
|
||||
if !ok {
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
return base, true
|
||||
}
|
||||
return ModelEntry{}, false
|
||||
}
|
||||
|
||||
// HasFastVariant reports whether id has an available fast variant — i.e. whether
|
||||
// `/fast on` would switch to a faster model from here. It is the predicate the
|
||||
// TUI uses to show the "f" fast-mode marker on a model (in the model/provider
|
||||
// pickers and the active-model indicator).
|
||||
func (registry Registry) HasFastVariant(id string) bool {
|
||||
_, ok := registry.FastVariant(id)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package modelregistry
|
||||
|
||||
import "testing"
|
||||
|
||||
func testRegistry(t *testing.T) Registry {
|
||||
t.Helper()
|
||||
registry, err := DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry() error = %v", err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// expectedFastPairs is the known-good base->fast mapping. It is a literal
|
||||
// regression guard: if the catalog silently drops or mis-wires a FastVariantID,
|
||||
// the forward/reverse assertions below fail loudly rather than making /fast a
|
||||
// quiet no-op. Keep in sync with the FastVariantID fields in catalog.go.
|
||||
var expectedFastPairs = map[string]string{
|
||||
"claude-sonnet-4.5": "claude-haiku-4.5",
|
||||
"gpt-4.1": "gpt-4.1-mini",
|
||||
"gpt-4o": "gpt-4o-mini",
|
||||
"gemini-2.5-pro": "gemini-2.5-flash",
|
||||
}
|
||||
|
||||
// Every FastVariantID declared in the catalog must resolve to a real, non-self,
|
||||
// non-deprecated model. NewRegistry already rejects an unresolvable id at build
|
||||
// time; this additionally guards the runtime availability contract that /fast and
|
||||
// the "f" marker depend on.
|
||||
func TestCatalogFastVariantsResolve(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
|
||||
if entry.FastVariantID == "" {
|
||||
continue
|
||||
}
|
||||
target, ok := registry.Resolve(entry.FastVariantID)
|
||||
if !ok {
|
||||
t.Errorf("model %q fast variant %q does not resolve", entry.ID, entry.FastVariantID)
|
||||
continue
|
||||
}
|
||||
if target.ID == entry.ID {
|
||||
t.Errorf("model %q names itself as its fast variant", entry.ID)
|
||||
}
|
||||
if target.Status == ModelStatusDeprecated {
|
||||
t.Errorf("model %q fast variant %q is deprecated", entry.ID, entry.FastVariantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No model may be both a base and a fast, or the two directions become ambiguous
|
||||
// (a model would be "currently fast" AND advertise a fast variant). Derived from
|
||||
// the registry so a bad catalog edit — e.g. chaining mini->nano — is caught.
|
||||
func TestFastVariantPairsUnambiguous(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
role := map[string]string{} // canonical id -> "base" | "fast"
|
||||
assign := func(id, r string) {
|
||||
entry, ok := registry.Resolve(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if prev, seen := role[entry.ID]; seen && prev != r {
|
||||
t.Fatalf("model %q is declared as both a base and a fast variant", entry.ID)
|
||||
}
|
||||
role[entry.ID] = r
|
||||
}
|
||||
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
|
||||
if entry.FastVariantID == "" {
|
||||
continue
|
||||
}
|
||||
assign(entry.ID, "base")
|
||||
assign(entry.FastVariantID, "fast")
|
||||
}
|
||||
}
|
||||
|
||||
// The known pairs must be present and wired in both directions, and neither end
|
||||
// may leak into the other role (a base is not itself fast; a fast has no fast).
|
||||
func TestFastVariantAndBaseVariant(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
|
||||
for base, fast := range expectedFastPairs {
|
||||
// base -> fast (forward), and the fast id has NO fast variant of its own.
|
||||
if got, ok := registry.FastVariant(base); !ok {
|
||||
t.Errorf("FastVariant(%q) = not found, want the fast variant", base)
|
||||
} else if canon, _ := registry.Resolve(fast); got.ID != canon.ID {
|
||||
t.Errorf("FastVariant(%q).ID = %q, want %q", base, got.ID, canon.ID)
|
||||
}
|
||||
if _, ok := registry.FastVariant(fast); ok {
|
||||
t.Errorf("FastVariant(%q) resolved, but a fast model should have no fast variant", fast)
|
||||
}
|
||||
|
||||
// fast -> base (reverse), and the base id is NOT itself a fast variant.
|
||||
if got, ok := registry.BaseVariant(fast); !ok {
|
||||
t.Errorf("BaseVariant(%q) = not found, want the base variant", fast)
|
||||
} else if canon, _ := registry.Resolve(base); got.ID != canon.ID {
|
||||
t.Errorf("BaseVariant(%q).ID = %q, want %q", fast, got.ID, canon.ID)
|
||||
}
|
||||
if _, ok := registry.BaseVariant(base); ok {
|
||||
t.Errorf("BaseVariant(%q) resolved, but a base model is not a fast variant", base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasFastVariant is the TUI marker predicate: true only for base models with an
|
||||
// available fast variant, false for the fast models themselves, unpaired models,
|
||||
// and unknown ids.
|
||||
func TestHasFastVariant(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
for base, fast := range expectedFastPairs {
|
||||
if !registry.HasFastVariant(base) {
|
||||
t.Errorf("HasFastVariant(%q) = false, want true", base)
|
||||
}
|
||||
if registry.HasFastVariant(fast) {
|
||||
t.Errorf("HasFastVariant(%q) = true, want false (a fast model has no fast variant)", fast)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"claude-opus-4.1", "definitely-not-a-real-model", ""} {
|
||||
if registry.HasFastVariant(id) {
|
||||
t.Errorf("HasFastVariant(%q) = true, want false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFastVariantAbsentAndUnknown(t *testing.T) {
|
||||
registry := testRegistry(t)
|
||||
|
||||
// A real model with no configured fast/base variant reports none in both
|
||||
// directions (a valid, handled case — /fast will say "no fast mode available").
|
||||
const noVariant = "claude-opus-4.1"
|
||||
if _, ok := registry.Resolve(noVariant); !ok {
|
||||
t.Skipf("%q not in registry; skipping no-variant case", noVariant)
|
||||
}
|
||||
if _, ok := registry.FastVariant(noVariant); ok {
|
||||
t.Errorf("FastVariant(%q) resolved, want none", noVariant)
|
||||
}
|
||||
if _, ok := registry.BaseVariant(noVariant); ok {
|
||||
t.Errorf("BaseVariant(%q) resolved, want none", noVariant)
|
||||
}
|
||||
|
||||
// An unknown id resolves to nothing in either direction.
|
||||
if _, ok := registry.FastVariant("definitely-not-a-real-model"); ok {
|
||||
t.Error("FastVariant(unknown) resolved, want none")
|
||||
}
|
||||
if _, ok := registry.BaseVariant("definitely-not-a-real-model"); ok {
|
||||
t.Error("BaseVariant(unknown) resolved, want none")
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too — mirroring acquireFileLock in lock.go —
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
|
||||
@@ -123,8 +123,10 @@ var descriptors = []Descriptor{
|
||||
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||
anthropicCompat("minimaxi-cn", "MiniMax CN", "https://api.minimaxi.com/anthropic", "MiniMax-M3", []string{"MINIMAXI_API_KEY"}, "minimax cn", "minimax-cn"),
|
||||
openAICompat("mistral", "Mistral", "https://api.mistral.ai/v1", "mistral-large-latest", []string{"MISTRAL_API_KEY"}),
|
||||
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
|
||||
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
|
||||
@@ -133,7 +135,8 @@ var descriptors = []Descriptor{
|
||||
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://api.z.ai/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY"}, "z.ai"),
|
||||
openAICompat("zai-cn", "Z.ai CN", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZHIPU_API_KEY"}, "z.ai cn"),
|
||||
openAICompat("kilocode", "KiloCode", "https://api.kilo.ai/api/gateway", "anthropic/claude-sonnet-4.6", []string{"KILO_API_KEY"}, "kilo", "kilo gateway"),
|
||||
openAICompat("opencode", "OpenCode Zen", "https://opencode.ai/zen/v1", "deepseek-v4-flash", []string{"OPENCODE_API_KEY"}, "opencode zen"),
|
||||
openAICompat("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", "deepseek-v4-pro", []string{"OPENCODE_API_KEY"}, "opencode go"),
|
||||
|
||||
@@ -23,8 +23,10 @@ var expectedCatalogIDs = []string{
|
||||
"together",
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
"minimaxi-cn",
|
||||
"mistral",
|
||||
"github",
|
||||
"bedrock",
|
||||
@@ -34,6 +36,7 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"zai-cn",
|
||||
"kilocode",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
@@ -99,6 +102,28 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongCatDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("longcat")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(longcat) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "LongCat" {
|
||||
t.Fatalf("Name = %q, want LongCat", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.longcat.chat/openai" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want LongCat OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "LongCat-2.0" {
|
||||
t.Fatalf("DefaultModel = %q, want LongCat-2.0", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"LONGCAT_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want LONGCAT_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -266,8 +291,8 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportGoogle: {"google"},
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
if err := validateEndpoint(ctx, ep, r, true); err != nil {
|
||||
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
|
||||
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
|
||||
}
|
||||
|
||||
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
|
||||
// link-local, private) stay blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
|
||||
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
|
||||
// link-local and other special ranges (cloud metadata, documentation) stay
|
||||
// blocked even for a local provider config.
|
||||
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
|
||||
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
|
||||
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
|
||||
}
|
||||
if endpointIsLoopback(ep) {
|
||||
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
|
||||
if endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
|
||||
}
|
||||
}
|
||||
|
||||
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
|
||||
// now allowed when the flag is true — the user is pointing at their own
|
||||
// LAN box (e.g. llama.cpp on another local machine).
|
||||
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
|
||||
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
|
||||
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
|
||||
}
|
||||
if !endpointIsLoopbackOrPrivate(ep) {
|
||||
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
requestCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
|
||||
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
|
||||
if err != nil {
|
||||
category := CategoryConfig
|
||||
var safety endpointSafetyError
|
||||
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
|
||||
}
|
||||
client := options.HTTPClient
|
||||
if client == nil {
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
|
||||
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||
allowLoopback := endpointIsLoopback(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
|
||||
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
|
||||
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
request.Header.Set("User-Agent", options.UserAgent)
|
||||
}
|
||||
applyAuth(request, profile, kind)
|
||||
return request, allowLoopback, nil
|
||||
return request, allowLoopbackOrPrivate, nil
|
||||
}
|
||||
|
||||
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
|
||||
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
|
||||
}
|
||||
|
||||
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
|
||||
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
|
||||
// set true exclusively for the user's own explicitly-configured local provider
|
||||
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
|
||||
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
|
||||
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
|
||||
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
|
||||
// is set true exclusively for the user's own explicitly-configured base_url
|
||||
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
|
||||
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
}
|
||||
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
|
||||
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
|
||||
if allowLoopback {
|
||||
if allowLoopbackOrPrivate {
|
||||
return nil // user-configured local provider; loopback is intentional
|
||||
}
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
|
||||
}
|
||||
if addr, err := netip.ParseAddr(normalized); err == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return nil
|
||||
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
|
||||
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
|
||||
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
|
||||
// decide whether the connectivity probe may reach the user's own local provider.
|
||||
func endpointIsLoopback(endpoint string) bool {
|
||||
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
|
||||
// or private-network host the user typed themselves — a local provider on the LAN
|
||||
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
|
||||
// connectivity probe may relax address-blocking and reach the user's own
|
||||
// explicitly-configured provider.
|
||||
func endpointIsLoopbackOrPrivate(endpoint string) bool {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
|
||||
return true
|
||||
}
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
return addr.IsLoopback()
|
||||
return addr.IsLoopback() || addr.IsPrivate()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
|
||||
// time and then dials the validated IP literal, closing the TOCTOU window
|
||||
// between the pre-flight validateEndpoint check and the actual connection
|
||||
// (DNS rebinding).
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
|
||||
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
|
||||
if resolver == nil {
|
||||
resolver = defaultResolver{}
|
||||
}
|
||||
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
} else {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopback)
|
||||
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
|
||||
}
|
||||
}
|
||||
// Redirects are NEVER allowed to reach loopback, even for a local provider:
|
||||
// allowLoopback covers only the user's own configured base_url, not a 3xx
|
||||
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
// allowLoopbackOrPrivate covers only the user's own configured base_url,
|
||||
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
|
||||
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
|
||||
},
|
||||
}
|
||||
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
|
||||
// safeDialContext returns a dial function that resolves the target host, refuses
|
||||
// the connection if any resolved address is blocked, then dials the validated IP
|
||||
// literal so the kernel cannot re-resolve to a different address after the check.
|
||||
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, err
|
||||
}
|
||||
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
return dialer.DialContext(ctx, network, address)
|
||||
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
|
||||
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
|
||||
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
|
||||
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@ type Model struct {
|
||||
Source string
|
||||
}
|
||||
|
||||
// Shared by both "minimax" and "minimaxi-cn"; Models() rebuilds a fresh slice
|
||||
// per call, so the shared backing slice cannot be mutated by callers.
|
||||
var minimaxCuratedModels = []Model{
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
}
|
||||
|
||||
// Shared by both "zai" (international, api.z.ai) and "zai-cn" (China,
|
||||
// open.bigmodel.cn); same model lineup on both endpoints.
|
||||
var zaiCuratedModels = []Model{
|
||||
{ID: "glm-4.5", Description: "catalog default"},
|
||||
{ID: "glm-4.5-air", Description: "fast model"},
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
}
|
||||
|
||||
var curatedModels = map[string][]Model{
|
||||
"ollama-cloud": {
|
||||
{ID: "qwen3-coder:480b", Description: "catalog default"},
|
||||
@@ -88,10 +104,8 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||
{ID: "mistralai/mixtral-8x7b-instruct-v0.1", Description: "mixture model"},
|
||||
},
|
||||
"minimax": {
|
||||
{ID: "MiniMax-M3", Description: "catalog default"},
|
||||
{ID: "MiniMax-M2.1", Description: "agentic coding model"},
|
||||
},
|
||||
"minimax": minimaxCuratedModels,
|
||||
"minimaxi-cn": minimaxCuratedModels,
|
||||
"mistral": {
|
||||
{ID: "mistral-large-latest", Description: "catalog default"},
|
||||
{ID: "codestral-latest", Description: "coding model"},
|
||||
@@ -123,12 +137,8 @@ var curatedModels = map[string][]Model{
|
||||
"bankr": {
|
||||
{ID: "bankr-large", Description: "catalog default"},
|
||||
},
|
||||
"zai": {
|
||||
{ID: "glm-4.5", Description: "catalog default"},
|
||||
{ID: "glm-4.5-air", Description: "fast model"},
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
},
|
||||
"zai": zaiCuratedModels,
|
||||
"zai-cn": zaiCuratedModels,
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||
|
||||
@@ -37,6 +37,16 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "minimaxi-cn",
|
||||
want: []string{"MiniMax-M3", "MiniMax-M2.1"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
{
|
||||
provider: "zai-cn",
|
||||
want: []string{"glm-4.5", "glm-4.6", "glm-4.5-air", "glm-z1-air"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -88,3 +98,66 @@ func modelIDs(models []Model) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Pins the contract that MiniMax CN exposes exactly the same model IDs as the
|
||||
// international provider; a divergence here is almost always a mistake.
|
||||
func TestMiniMaxCNModelsMirrorInternational(t *testing.T) {
|
||||
intl, ok := providercatalog.Get("minimax")
|
||||
if !ok {
|
||||
t.Fatal("provider minimax missing from catalog")
|
||||
}
|
||||
cn, ok := providercatalog.Get("minimaxi-cn")
|
||||
if !ok {
|
||||
t.Fatal("provider minimaxi-cn missing from catalog")
|
||||
}
|
||||
intlModels := Models(intl)
|
||||
cnModels := Models(cn)
|
||||
if len(intlModels) != len(cnModels) {
|
||||
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||
}
|
||||
intlIDs := map[string]string{}
|
||||
for _, m := range intlModels {
|
||||
intlIDs[m.ID] = m.Description
|
||||
}
|
||||
for _, m := range cnModels {
|
||||
wantDesc, ok := intlIDs[m.ID]
|
||||
if !ok {
|
||||
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||
}
|
||||
if m.Description != wantDesc {
|
||||
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same contract as TestMiniMaxCNModelsMirrorInternational, for Z.ai: the
|
||||
// international (api.z.ai) and China (open.bigmodel.cn) endpoints expose the
|
||||
// same model lineup.
|
||||
func TestZaiCNModelsMirrorInternational(t *testing.T) {
|
||||
intl, ok := providercatalog.Get("zai")
|
||||
if !ok {
|
||||
t.Fatal("provider zai missing from catalog")
|
||||
}
|
||||
cn, ok := providercatalog.Get("zai-cn")
|
||||
if !ok {
|
||||
t.Fatal("provider zai-cn missing from catalog")
|
||||
}
|
||||
intlModels := Models(intl)
|
||||
cnModels := Models(cn)
|
||||
if len(intlModels) != len(cnModels) {
|
||||
t.Fatalf("model count mismatch: international=%d china=%d", len(intlModels), len(cnModels))
|
||||
}
|
||||
intlIDs := map[string]string{}
|
||||
for _, m := range intlModels {
|
||||
intlIDs[m.ID] = m.Description
|
||||
}
|
||||
for _, m := range cnModels {
|
||||
wantDesc, ok := intlIDs[m.ID]
|
||||
if !ok {
|
||||
t.Fatalf("china provider exposes %q which is not in the international catalog", m.ID)
|
||||
}
|
||||
if m.Description != wantDesc {
|
||||
t.Fatalf("model %q description diverged: international=%q china=%q", m.ID, wantDesc, m.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ func ModelsDevProviderID(provider providercatalog.Descriptor) string {
|
||||
return "nvidia"
|
||||
case "xiaomi-mimo":
|
||||
return "xiaomi"
|
||||
case "zai-cn":
|
||||
return "zai"
|
||||
case "minimaxi-cn":
|
||||
return "minimax"
|
||||
default:
|
||||
return strings.TrimSpace(provider.ID)
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
|
||||
"xiaomi-mimo": "xiaomi",
|
||||
"dashscope": "alibaba",
|
||||
"ollama-cloud": "ollama-cloud",
|
||||
"zai-cn": "zai",
|
||||
"minimaxi-cn": "minimax",
|
||||
}
|
||||
for zeroID, want := range tests {
|
||||
provider, ok := providercatalog.Get(zeroID)
|
||||
|
||||
@@ -276,16 +276,6 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
|
||||
return true
|
||||
}
|
||||
|
||||
// firstNonEmptyString returns the first argument that is not empty.
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (provider *Provider) emitChunk(
|
||||
ctx context.Context,
|
||||
chunk streamChunk,
|
||||
@@ -293,12 +283,10 @@ func (provider *Provider) emitChunk(
|
||||
events chan<- zeroruntime.StreamEvent,
|
||||
) {
|
||||
for _, choice := range chunk.Choices {
|
||||
// Two dialects for the same thing (see streamDelta): prefer
|
||||
// `reasoning_content`, fall back to `reasoning`. No backend sends both.
|
||||
if reasoning := firstNonEmptyString(choice.Delta.ReasoningContent, choice.Delta.Reasoning); reasoning != "" {
|
||||
if choice.Delta.ReasoningContent != "" {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: reasoning,
|
||||
Content: choice.Delta.ReasoningContent,
|
||||
})
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
|
||||
@@ -227,33 +227,6 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama's OpenAI-compat endpoint, OpenRouter, and most gateways stream
|
||||
// thinking under `reasoning` (not DeepSeek's `reasoning_content`). Dropping it
|
||||
// made every thinking token on those backends vanish: the model looked frozen
|
||||
// for minutes and reasoning-only turns were indistinguishable from a dead
|
||||
// provider. The exact shape below is a live capture from ollama glm-5.2:cloud.
|
||||
func TestStreamCompletionEmitsOllamaStyleReasoningDeltas(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"The user"},"finish_reason":null}]}`)
|
||||
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":" wants hello"},"finish_reason":null}]}`)
|
||||
writeSSE(w, `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"},"finish_reason":"stop"}]}`)
|
||||
writeSSE(w, `[DONE]`)
|
||||
})
|
||||
|
||||
events := collectProviderEvents(t, provider)
|
||||
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
|
||||
if len(reasoning) != 2 {
|
||||
t.Fatalf("reasoning events = %#v, want two reasoning deltas (ollama `reasoning` field regression)", reasoning)
|
||||
}
|
||||
if reasoning[0].Content != "The user" || reasoning[1].Content != " wants hello" {
|
||||
t.Fatalf("unexpected reasoning events: %#v", reasoning)
|
||||
}
|
||||
text := eventsOfType(events, zeroruntime.StreamEventText)
|
||||
if len(text) != 1 || text[0].Content != "hello" {
|
||||
t.Fatalf("content after thinking must still stream as text, got %#v", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
|
||||
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
|
||||
|
||||
@@ -76,16 +76,8 @@ type streamChoice struct {
|
||||
}
|
||||
|
||||
type streamDelta struct {
|
||||
Content string `json:"content"`
|
||||
// Reasoning/thinking deltas arrive under DIFFERENT keys depending on the
|
||||
// backend dialect: DeepSeek-style servers emit `reasoning_content`, while
|
||||
// ollama's OpenAI-compat endpoint, OpenRouter, and most gateways emit
|
||||
// `reasoning`. Parse both — dropping `reasoning` made every thinking token
|
||||
// on those backends silently vanish (the model looked frozen for minutes,
|
||||
// its thinking was never replayed into the next turn's context, and a
|
||||
// reasoning-only turn was indistinguishable from a dead provider).
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []streamToolCallDelta `json:"tool_calls"`
|
||||
}
|
||||
|
||||
|
||||
@@ -82,53 +82,6 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
|
||||
return idleTimeout + extra
|
||||
}
|
||||
|
||||
// DefaultStreamGapTimeout bounds the byte-silent gap AFTER a stream has
|
||||
// started delivering payloads. Once ANY payload (data or keep-alive) has
|
||||
// arrived, the backend has proven it heartbeats/streams — so a subsequent gap
|
||||
// with zero bytes is almost always a dead connection (live capture of the
|
||||
// reported "stuck for 33 minutes": ~119KB streamed, then the socket went
|
||||
// byte-frozen forever). Detecting that at 2 minutes instead of the 5-minute
|
||||
// idle default turns a ~15-minute blind wait (3 × 5m attempts) into a ~6-minute
|
||||
// bounded recovery, without touching the pre-first-byte window that slow cloud
|
||||
// proxies legitimately need (they may withhold output until the upstream model
|
||||
// produces its first token). Override with ZERO_STREAM_GAP_TIMEOUT. For
|
||||
// comparison: Codex ships the same 5m idle with 5 stream retries; opencode
|
||||
// suffers this exact freeze (idle HTTPS socket in "working" state) with no
|
||||
// mid-stream gap detection at all.
|
||||
const DefaultStreamGapTimeout = 2 * time.Minute
|
||||
|
||||
// streamGapTimeoutEnv overrides the post-first-payload gap timeout. Accepts the
|
||||
// same forms as ZERO_STREAM_IDLE_TIMEOUT; "0"/"off" disables the tightened gap
|
||||
// (the plain idle timeout then applies for the whole stream).
|
||||
const streamGapTimeoutEnv = "ZERO_STREAM_GAP_TIMEOUT"
|
||||
|
||||
// ResolveStreamGapTimeout selects the effective post-first-payload gap timeout
|
||||
// for a stream whose idle timeout resolved to idleTimeout. It is clamped to
|
||||
// never EXCEED the idle timeout (a gap larger than idle would be dead config),
|
||||
// and disabled entirely when the idle watchdog is disabled.
|
||||
func ResolveStreamGapTimeout(idleTimeout time.Duration) time.Duration {
|
||||
if idleTimeout <= 0 {
|
||||
return 0
|
||||
}
|
||||
gap := DefaultStreamGapTimeout
|
||||
if raw := strings.TrimSpace(os.Getenv(streamGapTimeoutEnv)); raw != "" {
|
||||
switch strings.ToLower(raw) {
|
||||
case "0", "off", "none", "disabled":
|
||||
return idleTimeout // no tightened gap: idle applies throughout
|
||||
default:
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
gap = d
|
||||
} else if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
|
||||
gap = time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
if gap > idleTimeout {
|
||||
return idleTimeout
|
||||
}
|
||||
return gap
|
||||
}
|
||||
|
||||
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
|
||||
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
|
||||
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
|
||||
@@ -364,14 +317,7 @@ func ScanSSEDataWithContext(
|
||||
idle := time.NewTimer(idleTimeout)
|
||||
defer idle.Stop()
|
||||
idleC = idle.C
|
||||
// The timer was armed above with the FULL idle window, which governs
|
||||
// until the first payload (slow cloud proxies may withhold output until
|
||||
// the upstream model produces its first token). resetIdle only runs when
|
||||
// a payload HAS arrived — the backend has proven it streams/heartbeats —
|
||||
// so every re-arm uses the tighter gap window: a byte-frozen socket
|
||||
// mid-stream is a dead connection, and waiting the full idle for it just
|
||||
// multiplies the user's blind wait per retry.
|
||||
resetIdle = reset(idle, ResolveStreamGapTimeout(idleTimeout))
|
||||
resetIdle = reset(idle, idleTimeout)
|
||||
|
||||
// Content watchdog: only real data lines reset it (keep-alives do not), so a
|
||||
// stream that heartbeats without producing output is bounded instead of
|
||||
|
||||
@@ -371,86 +371,3 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
t.Fatal("an explicit client must be returned unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
// Once a stream has delivered its first payload, a byte-silent gap is bounded
|
||||
// by the TIGHTER gap timeout, not the full idle window — a mid-stream frozen
|
||||
// socket (live-captured "stuck" shape: bytes flowed, then nothing forever)
|
||||
// must be detected fast so retries aren't multiplied by five-minute waits.
|
||||
func TestScanSSETightensGapAfterFirstPayload(t *testing.T) {
|
||||
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "80ms")
|
||||
pr, pw := io.Pipe()
|
||||
defer func() { _ = pw.Close() }()
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(pw, "data: first\n\n")
|
||||
// then: byte-frozen forever
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 2*time.Second, func(string) bool { return true })
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, ErrStreamIdle) {
|
||||
t.Fatalf("err = %v, want ErrStreamIdle", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("gap abort took %s — the tightened window (80ms) did not apply after the first payload", elapsed)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("stream not aborted")
|
||||
}
|
||||
}
|
||||
|
||||
// BEFORE the first payload the FULL idle window applies: slow cloud proxies may
|
||||
// withhold output until the upstream model emits its first token, so the gap
|
||||
// timeout must NOT govern the wait for the first byte.
|
||||
func TestScanSSEKeepsFullIdleBeforeFirstPayload(t *testing.T) {
|
||||
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "30ms")
|
||||
pr, pw := io.Pipe()
|
||||
defer func() { _ = pw.Close() }()
|
||||
// never write anything
|
||||
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 300*time.Millisecond, func(string) bool { return true })
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, ErrStreamIdle) {
|
||||
t.Fatalf("err = %v, want ErrStreamIdle", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
|
||||
t.Fatalf("aborted after %s — the 30ms gap timeout must not apply before the first payload", elapsed)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("stream not aborted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStreamGapTimeout(t *testing.T) {
|
||||
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "")
|
||||
if got := ResolveStreamGapTimeout(5 * time.Minute); got != DefaultStreamGapTimeout {
|
||||
t.Fatalf("default gap = %s, want %s", got, DefaultStreamGapTimeout)
|
||||
}
|
||||
// Clamped: the gap never exceeds the idle window.
|
||||
if got := ResolveStreamGapTimeout(time.Minute); got != time.Minute {
|
||||
t.Fatalf("gap = %s, want clamp to the 1m idle", got)
|
||||
}
|
||||
// Disabled idle disables the gap too.
|
||||
if got := ResolveStreamGapTimeout(0); got != 0 {
|
||||
t.Fatalf("gap with idle off = %s, want 0", got)
|
||||
}
|
||||
// Env override wins; "off" falls back to the idle window for the whole stream.
|
||||
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "45s")
|
||||
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 45*time.Second {
|
||||
t.Fatalf("env gap = %s, want 45s", got)
|
||||
}
|
||||
t.Setenv("ZERO_STREAM_GAP_TIMEOUT", "off")
|
||||
if got := ResolveStreamGapTimeout(5 * time.Minute); got != 5*time.Minute {
|
||||
t.Fatalf("off gap = %s, want the idle window", got)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ var errWindowsSandboxNotInitialized = errors.New(
|
||||
const windowsSetupDowngradeReason = "Windows OS sandbox inactive — commands run with workspace path-confinement and " +
|
||||
"per-command approval only. Run `zero sandbox setup` from an elevated (Administrator) terminal for full filesystem and network isolation."
|
||||
|
||||
// windowsUnelevatedDowngradeReason is surfaced when Windows runs the sandbox in
|
||||
// the unelevated tier: the restricted-token write-jail is enforced (the command
|
||||
// runner applies the workspace ACLs itself, which needs no Administrator
|
||||
// rights), but the WFP network filters are Administrator-only and absent.
|
||||
const windowsUnelevatedDowngradeReason = "Windows sandbox running unelevated — the filesystem write-jail is enforced, but network " +
|
||||
"isolation still relies on per-command approval. Run `zero sandbox setup` from an elevated (Administrator) terminal for OS-level network enforcement."
|
||||
|
||||
// windowsSandboxInitialized reports whether the per-host Windows sandbox setup
|
||||
// marker exists. A missing marker is the common fresh-install state, so the
|
||||
// caller degrades rather than bricking the command. Indirected through a var so
|
||||
@@ -174,19 +181,28 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
if request.ValidateExecution && preference == SandboxPreferenceRequire && backend.SupportLevel() != BackendSupportNative {
|
||||
return SandboxExecutionRequest{}, nativeSandboxUnavailableError(backend)
|
||||
}
|
||||
// Windows: the OS sandbox needs a one-time elevated `zero sandbox setup` (it
|
||||
// applies WFP network filters + workspace ACLs and writes a marker). Without
|
||||
// it the command runner hard-fails — so on the default preference, DEGRADE to
|
||||
// the in-process policy gate + per-command approval (matching Linux/macOS when
|
||||
// their sandbox is unavailable) instead of bricking every command. A strict
|
||||
// caller (--sandbox require) still gets a clear error pointing at setup.
|
||||
// Windows: the FULL OS sandbox needs a one-time elevated `zero sandbox setup`
|
||||
// (it applies WFP network filters + workspace ACLs and writes a marker).
|
||||
// Without it, a restricted-filesystem profile can still run in the UNELEVATED
|
||||
// tier: the command runner applies the workspace ACL plan itself (DACL edits
|
||||
// on user-owned roots need no Administrator rights) and wraps the command in
|
||||
// the write-restricted token, so the filesystem write-jail holds. Only the
|
||||
// WFP network filters are Administrator-only, so network stays with the
|
||||
// in-process approval gate at that tier. A profile that needs the sandbox
|
||||
// solely for network isolation gains nothing from the unelevated tier and
|
||||
// DEGRADES to the policy gate as before. A strict caller (--sandbox require)
|
||||
// still gets a clear error pointing at setup.
|
||||
windowsNeedsSetup := false
|
||||
if manager.goos == "windows" && requiresPlatformSandbox && enforcementLevel == EnforcementNative && !windowsSandboxInitialized() {
|
||||
if preference == SandboxPreferenceRequire {
|
||||
return SandboxExecutionRequest{}, errWindowsSandboxNotInitialized
|
||||
}
|
||||
enforcementLevel = EnforcementDegraded
|
||||
windowsNeedsSetup = true
|
||||
if profile.FileSystem.Kind == FileSystemRestricted {
|
||||
enforcementLevel = EnforcementUnelevated
|
||||
} else {
|
||||
enforcementLevel = EnforcementDegraded
|
||||
}
|
||||
}
|
||||
targetBackend := manager.targetBackend(preference, policy, requiresPlatformSandbox)
|
||||
downgradeReason := ""
|
||||
@@ -196,7 +212,11 @@ func (manager SandboxManager) BuildExecutionRequest(request SandboxManagerReques
|
||||
downgradeReason = windowsSetupDowngradeReason
|
||||
}
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available && enforcementLevel == EnforcementNative
|
||||
if enforcementLevel == EnforcementUnelevated {
|
||||
downgradeReason = windowsUnelevatedDowngradeReason
|
||||
}
|
||||
wrapped := backend.CommandWrapping && backend.Available &&
|
||||
(enforcementLevel == EnforcementNative || enforcementLevel == EnforcementUnelevated)
|
||||
markers := backend.SandboxEnvMarkers(policy)
|
||||
if !wrapped && backend.Name != BackendWSL {
|
||||
markers = nil
|
||||
|
||||
@@ -72,6 +72,9 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
|
||||
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
|
||||
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
|
||||
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,64 @@ exit 0
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsUnelevatedRealSandboxSmoke exercises the unelevated tier end to
|
||||
// end WITHOUT running the elevated setup helper: the command runner applies
|
||||
// the workspace ACLs itself, a write inside the workspace succeeds under the
|
||||
// restricted token, and a write outside every granted root is denied. Unlike
|
||||
// the elevated smoke above it needs no Administrator terminal.
|
||||
func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
|
||||
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
|
||||
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
|
||||
}
|
||||
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
|
||||
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
sandboxHome := filepath.Join(root, ".zero-sandbox")
|
||||
profile := PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{
|
||||
Kind: FileSystemRestricted,
|
||||
ReadRoots: []string{root},
|
||||
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
|
||||
IncludePlatformRoots: true,
|
||||
AllowTemp: true,
|
||||
},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
}
|
||||
config := WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: root,
|
||||
WorkspaceRoots: []string{root},
|
||||
PermissionProfile: profile,
|
||||
SandboxLevel: WindowsSandboxLevelUnelevated,
|
||||
}
|
||||
|
||||
// cmd.exe rather than powershell.exe: managed PowerShell cannot initialize
|
||||
// its crypto provider under a write-restricted token on some hosts
|
||||
// (0x8009001d, the same restricted-token limitation the runner documents for
|
||||
// Schannel), and the write-jail assertion only needs a native shell.
|
||||
insideMarker := filepath.Join(root, "unelevated-write-ok.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", "echo ok>" + insideMarker,
|
||||
}, 0)
|
||||
if bytes, err := os.ReadFile(insideMarker); err != nil || strings.TrimSpace(string(bytes)) != "ok" {
|
||||
t.Fatalf("unelevated sandboxed write marker = %q, %v; want ok", bytes, err)
|
||||
}
|
||||
if _, err := os.Stat(WindowsUnelevatedSetupMarkerPath(sandboxHome)); err != nil {
|
||||
t.Fatalf("expected the unelevated setup marker to be recorded: %v", err)
|
||||
}
|
||||
|
||||
outsideMarker := filepath.Join(outside, "unelevated-write-denied.txt")
|
||||
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
|
||||
"cmd.exe", "/d", "/s", "/c", "echo leaked>" + outsideMarker,
|
||||
}, 1)
|
||||
if _, err := os.Stat(outsideMarker); err == nil {
|
||||
t.Fatalf("unelevated sandbox allowed a write outside every granted root")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat outside marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
|
||||
t.Helper()
|
||||
if path := os.Getenv(envKey); path != "" {
|
||||
|
||||
@@ -129,6 +129,9 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
|
||||
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
|
||||
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
|
||||
}
|
||||
// /tmp is a default temp write root on POSIX only (see
|
||||
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
|
||||
// against the current drive, so a stray C:\tmp must not turn this on.
|
||||
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
|
||||
t.Fatalf("Roots()=%v want default /tmp write root", roots)
|
||||
}
|
||||
|
||||
@@ -143,9 +143,16 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
EnforcementNative EnforcementLevel = "native"
|
||||
// EnforcementUnelevated is the Windows-only middle tier used when the
|
||||
// elevated `zero sandbox setup` has not run: commands still wrap through the
|
||||
// command runner with a write-restricted token and workspace ACLs (which the
|
||||
// runner can apply without Administrator rights), but the WFP network
|
||||
// filters are absent, so network isolation stays with the in-process
|
||||
// approval gate.
|
||||
EnforcementUnelevated EnforcementLevel = "unelevated"
|
||||
EnforcementDegraded EnforcementLevel = "degraded"
|
||||
EnforcementDisabled EnforcementLevel = "disabled"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
|
||||
@@ -8,14 +8,21 @@ import (
|
||||
)
|
||||
|
||||
func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writer) int {
|
||||
if config.SandboxLevel != WindowsSandboxLevelRestrictedToken {
|
||||
switch config.SandboxLevel {
|
||||
case WindowsSandboxLevelRestrictedToken:
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
case WindowsSandboxLevelUnelevated:
|
||||
if err := ensureWindowsUnelevatedSetup(config); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(stderr, "%s: unsupported Windows sandbox level %q\n", WindowsSandboxCommandRunnerName, config.SandboxLevel)
|
||||
return 1
|
||||
}
|
||||
if err := ValidateWindowsSandboxSetupMarker(WindowsSandboxSetupConfigFromCommand(config)); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
}
|
||||
if err := ValidateWindowsNetworkPolicy(config.PermissionProfile.Network); err != nil {
|
||||
fmt.Fprintln(stderr, WindowsSandboxCommandRunnerName+": "+err.Error())
|
||||
return 1
|
||||
@@ -57,3 +64,32 @@ func runWindowsSandboxCommand(config WindowsSandboxCommandConfig, stderr io.Writ
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ensureWindowsUnelevatedSetup applies the workspace ACL plan from the current
|
||||
// (non-elevated) process so the write-restricted token has somewhere its
|
||||
// capability SIDs are granted. DACL edits on user-owned workspace and temp
|
||||
// roots need no Administrator rights; the WFP network filters DO, so this tier
|
||||
// provisions no network enforcement — the offline-marker SID composed into the
|
||||
// token stays inert until an elevated `zero sandbox setup` installs the block
|
||||
// filters. Applied plans are recorded by hash so repeat commands skip the
|
||||
// re-apply; like the elevated setup, grants are left in place (the rollback is
|
||||
// deliberately discarded) because they only name synthetic capability SIDs
|
||||
// that no other token carries.
|
||||
func ensureWindowsUnelevatedSetup(config WindowsSandboxCommandConfig) error {
|
||||
applied, plan, err := buildWindowsUnelevatedAppliedPlan(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(config.SandboxHome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marker.contains(applied) {
|
||||
return nil
|
||||
}
|
||||
if _, err := applyWindowsACLPlan(plan); err != nil {
|
||||
return fmt.Errorf("apply unelevated workspace ACLs: %w — the workspace may be on a filesystem the current user does not own; "+
|
||||
"run `zero sandbox setup` from an elevated (Administrator) terminal, or re-run with `--sandbox forbid` to skip OS sandboxing", err)
|
||||
}
|
||||
return recordWindowsUnelevatedAppliedPlan(config.SandboxHome, applied)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package sandbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// On Windows, a not-yet-set-up sandbox must DEGRADE (run unwrapped with a
|
||||
// downgrade reason) on the default preference — never brick the command — while
|
||||
// a strict --sandbox require still errors with a setup hint. Once the marker
|
||||
// exists, enforcement is native and the command wraps.
|
||||
func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
// On Windows, a not-yet-set-up sandbox must fall back to the UNELEVATED tier
|
||||
// (wrapped through the runner with the write-restricted token, network left to
|
||||
// the approval gate) on the default preference — never brick the command —
|
||||
// while a strict --sandbox require still errors with a setup hint. Once the
|
||||
// marker exists, enforcement is native and the command wraps.
|
||||
func TestWindowsFallsBackUnelevatedWhenSandboxNotInitialized(t *testing.T) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
|
||||
@@ -24,13 +26,70 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
ValidateExecution: true,
|
||||
}
|
||||
|
||||
// Marker missing + auto -> degrade (unwrapped, with a reason), no error.
|
||||
// Marker missing + auto -> unelevated tier: still wrapped, with a reason
|
||||
// documenting the missing network enforcement, no error.
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
auto := base
|
||||
auto.Preference = SandboxPreferenceAuto
|
||||
req, err := mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("auto must degrade, not error: %v", err)
|
||||
t.Fatalf("auto must fall back, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementUnelevated {
|
||||
t.Fatalf("enforcement = %v, want unelevated", req.EnforcementLevel)
|
||||
}
|
||||
if !req.CommandWrapped {
|
||||
t.Fatal("unelevated command must still be wrapped through the sandbox runner")
|
||||
}
|
||||
if req.DowngradeReason == "" || !strings.Contains(req.DowngradeReason, "network") {
|
||||
t.Fatalf("downgrade reason = %q, want an explanation of the missing network enforcement", req.DowngradeReason)
|
||||
}
|
||||
|
||||
// Marker missing + require -> hard error pointing at setup.
|
||||
require := base
|
||||
require.Preference = SandboxPreferenceRequire
|
||||
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
|
||||
t.Fatalf("require must error with the setup hint, got %v", err)
|
||||
}
|
||||
|
||||
// Marker present -> native, wrapped, no downgrade reason.
|
||||
windowsSandboxInitialized = func() bool { return true }
|
||||
req, err = mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("initialized auto: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
|
||||
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
|
||||
}
|
||||
if req.DowngradeReason != "" {
|
||||
t.Fatalf("native downgrade reason = %q, want empty", req.DowngradeReason)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that needs the platform sandbox ONLY for network isolation gains
|
||||
// nothing from the unelevated tier (which cannot enforce network), so it must
|
||||
// keep the old degrade-to-policy-gate behavior.
|
||||
func TestWindowsNetworkOnlyProfileStillDegradesWhenNotInitialized(t *testing.T) {
|
||||
restore := windowsSandboxInitialized
|
||||
t.Cleanup(func() { windowsSandboxInitialized = restore })
|
||||
windowsSandboxInitialized = func() bool { return false }
|
||||
|
||||
mgr := NewSandboxManager(SandboxManagerOptions{
|
||||
GOOS: "windows",
|
||||
Backend: Backend{Name: BackendWindowsRestrictedToken, Available: true, Executable: "zero.exe", Platform: "windows"},
|
||||
})
|
||||
req, err := mgr.BuildExecutionRequest(SandboxManagerRequest{
|
||||
WorkspaceRoot: `C:\ws`,
|
||||
Command: CommandSpec{Name: "bash", Args: []string{"-c", "echo hi"}},
|
||||
Policy: DefaultPolicy(),
|
||||
Profile: PermissionProfile{
|
||||
FileSystem: FileSystemPolicy{Kind: FileSystemUnrestricted, IncludePlatformRoots: true, AllowTemp: true},
|
||||
Network: NetworkPolicy{Mode: NetworkDeny},
|
||||
},
|
||||
Preference: SandboxPreferenceAuto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("network-only profile must degrade, not error: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementDegraded {
|
||||
t.Fatalf("enforcement = %v, want degraded", req.EnforcementLevel)
|
||||
@@ -41,21 +100,4 @@ func TestWindowsDegradesWhenSandboxNotInitialized(t *testing.T) {
|
||||
if req.DowngradeReason == "" {
|
||||
t.Fatal("expected a downgrade reason explaining the missing setup")
|
||||
}
|
||||
|
||||
// Marker missing + require -> hard error pointing at setup.
|
||||
require := base
|
||||
require.Preference = SandboxPreferenceRequire
|
||||
if _, err := mgr.BuildExecutionRequest(require); !errors.Is(err, errWindowsSandboxNotInitialized) {
|
||||
t.Fatalf("require must error with the setup hint, got %v", err)
|
||||
}
|
||||
|
||||
// Marker present -> native, wrapped.
|
||||
windowsSandboxInitialized = func() bool { return true }
|
||||
req, err = mgr.BuildExecutionRequest(auto)
|
||||
if err != nil {
|
||||
t.Fatalf("initialized auto: %v", err)
|
||||
}
|
||||
if req.EnforcementLevel != EnforcementNative || !req.CommandWrapped {
|
||||
t.Fatalf("initialized -> native wrapped, got enforcement=%v wrapped=%v", req.EnforcementLevel, req.CommandWrapped)
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,14 @@ type WindowsSandboxLevel string
|
||||
|
||||
const (
|
||||
WindowsSandboxLevelRestrictedToken WindowsSandboxLevel = "restricted-token"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
// WindowsSandboxLevelUnelevated is the restricted-token tier without the
|
||||
// elevated setup: the runner applies the workspace ACL plan itself before
|
||||
// launching (no Administrator rights needed) and skips the elevated setup
|
||||
// marker check. Network is NOT enforced at this level — the WFP filters need
|
||||
// the elevated setup — so callers must keep network behind the approval gate.
|
||||
WindowsSandboxLevelUnelevated WindowsSandboxLevel = "unelevated"
|
||||
WindowsSandboxLevelElevated WindowsSandboxLevel = "elevated"
|
||||
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
|
||||
)
|
||||
|
||||
type WindowsSandboxCommandArgsOptions struct {
|
||||
@@ -271,13 +277,20 @@ func windowsRestrictedTokenCommandPlan(execRequest SandboxExecutionRequest, poli
|
||||
return CommandPlan{}, err
|
||||
}
|
||||
childEnv := windowsSandboxChildEnv(spec.Env, policy, execRequest.WorkspaceRoot)
|
||||
// The unelevated enforcement tier maps to the runner's unelevated level: same
|
||||
// restricted token, but the runner applies the workspace ACLs itself instead
|
||||
// of requiring the elevated setup marker.
|
||||
level := WindowsSandboxLevelRestrictedToken
|
||||
if execRequest.EnforcementLevel == EnforcementUnelevated {
|
||||
level = WindowsSandboxLevelUnelevated
|
||||
}
|
||||
args, err := BuildWindowsSandboxCommandArgs(WindowsSandboxCommandArgsOptions{
|
||||
SandboxHome: sandboxHome,
|
||||
CommandCWD: spec.Dir,
|
||||
WorkspaceRoots: []string{execRequest.WorkspaceRoot},
|
||||
PermissionProfile: execRequest.PermissionProfile,
|
||||
Env: childEnv,
|
||||
SandboxLevel: WindowsSandboxLevelRestrictedToken,
|
||||
SandboxLevel: level,
|
||||
Command: append([]string{spec.Name}, spec.Args...),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -381,7 +394,7 @@ func nextWindowsSandboxFlagValue(args []string, index int) (string, int, error)
|
||||
|
||||
func validWindowsSandboxLevel(level WindowsSandboxLevel) bool {
|
||||
switch level {
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
case WindowsSandboxLevelRestrictedToken, WindowsSandboxLevelUnelevated, WindowsSandboxLevelElevated, WindowsSandboxLevelDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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 or a corrupt/truncated body is likewise
|
||||
// treated as empty so the runner re-applies (and rewrites) rather than failing:
|
||||
// the marker only memoizes idempotent ACL applies, so resetting it can only
|
||||
// cost redundant work, never skip enforcement.
|
||||
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{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(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)
|
||||
// A truncated body must reset like an unknown schema, not brick every
|
||||
// unelevated command until the file is hand-deleted.
|
||||
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt marker: %v", err)
|
||||
}
|
||||
marker, err := loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
|
||||
}
|
||||
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
|
||||
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
|
||||
}
|
||||
// The healed marker must accept new recordings over the corrupt file.
|
||||
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
|
||||
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
|
||||
t.Fatalf("record over corrupt marker: %v", err)
|
||||
}
|
||||
marker, err = loadWindowsUnelevatedSetupMarker(home)
|
||||
if err != nil {
|
||||
t.Fatalf("reload healed marker: %v", err)
|
||||
}
|
||||
if !marker.contains(applied) {
|
||||
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -160,10 +160,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
|
||||
return prompt
|
||||
}
|
||||
events := prepared.ContextEvents
|
||||
if len(events) > 20 {
|
||||
events = events[len(events)-20:]
|
||||
}
|
||||
events := promptContextEvents(prepared.ContextEvents)
|
||||
|
||||
lines := []string{}
|
||||
for _, event := range events {
|
||||
@@ -187,6 +184,25 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func promptContextEvents(events []Event) []Event {
|
||||
const maxPromptContextEvents = 80
|
||||
|
||||
filtered := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
filtered = append(filtered, events...)
|
||||
}
|
||||
if len(filtered) > maxPromptContextEvents {
|
||||
filtered = filtered[len(filtered)-maxPromptContextEvents:]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func forkTitle(title string) string {
|
||||
if title == "" {
|
||||
return ""
|
||||
|
||||
@@ -71,7 +71,7 @@ type CompactionPayload struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
const defaultCompactionPreserveLast = 8
|
||||
const defaultCompactionPreserveLast = 6
|
||||
const defaultCompactionMaxPromptChars = 8000
|
||||
|
||||
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
|
||||
|
||||
@@ -555,6 +555,91 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
|
||||
events := []Event{
|
||||
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
|
||||
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
|
||||
}
|
||||
for sequence := 3; sequence <= 45; sequence++ {
|
||||
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
|
||||
}
|
||||
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, "noisy tool result") {
|
||||
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
|
||||
events := make([]Event, 0, 100)
|
||||
for sequence := 1; sequence <= 85; sequence++ {
|
||||
events = append(events, Event{
|
||||
Sequence: sequence,
|
||||
Type: EventMessage,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
|
||||
})
|
||||
if sequence%10 == 0 {
|
||||
events = append(events, Event{
|
||||
Sequence: 1000 + sequence,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
|
||||
})
|
||||
}
|
||||
}
|
||||
events = append(events, Event{
|
||||
Sequence: 2000,
|
||||
Type: EventToolResult,
|
||||
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
|
||||
})
|
||||
|
||||
contextEvents := promptContextEvents(events)
|
||||
if len(contextEvents) != 80 {
|
||||
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
|
||||
}
|
||||
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
|
||||
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
|
||||
}
|
||||
|
||||
prompt := FormatExecPrompt("continue", PreparedExec{
|
||||
Mode: ModeResume,
|
||||
Session: Metadata{SessionID: "long-session-with-noise"},
|
||||
ContextEvents: events,
|
||||
})
|
||||
|
||||
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
|
||||
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
|
||||
}
|
||||
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"conversation message 001",
|
||||
"conversation message 002",
|
||||
"conversation message 003",
|
||||
"conversation message 004",
|
||||
"conversation message 005",
|
||||
"noisy tool result",
|
||||
"trailing noisy tool result",
|
||||
} {
|
||||
if strings.Contains(prompt, unwanted) {
|
||||
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
|
||||
|
||||
|
||||
@@ -161,6 +161,35 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline -- pinokio_compat.py | head`,
|
||||
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
|
||||
`cat file.txt | wc -l`,
|
||||
`some_command | tail -n 20`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
|
||||
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`git log --oneline`,
|
||||
`dir /b`,
|
||||
`findstr /r "summary" file.txt`,
|
||||
`echo header text`,
|
||||
`tail-log.sh`,
|
||||
`grep-cli --version`,
|
||||
`sed.exe --help`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
|
||||
@@ -20,6 +20,11 @@ type shellIssue struct {
|
||||
var (
|
||||
windowsBashStyleCDPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)cd\s+/(?:[a-ce-z0-9_./~-]|d[a-z0-9_./~-])[a-z0-9_./~-]*`)
|
||||
windowsLSCommandPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)ls\b(?:\s+|$)`)
|
||||
// windowsPosixUtilityPattern catches the other common POSIX coreutils/grep
|
||||
// family commands cmd.exe has no equivalent for (unlike `dir`/`findstr`,
|
||||
// which sound similar enough that a model might reach for the POSIX name
|
||||
// instead) — most often seen piped in, e.g. `git log ... | head`.
|
||||
windowsPosixUtilityPattern = regexp.MustCompile(`(?i)(^|[&|;]\s*)(head|tail|grep|wc|awk|sed|cut|xargs|tr)(?:\s+|$)`)
|
||||
)
|
||||
|
||||
func detectShellRuntime(goos string) shellRuntime {
|
||||
@@ -57,6 +62,13 @@ func detectShellCommandIssue(command string, goos string) *shellIssue {
|
||||
Suggestion: "Use the cwd argument instead of cd, use Windows cmd.exe syntax, or use native tools such as list_directory, read_file, grep, and glob.",
|
||||
}
|
||||
}
|
||||
if windowsPosixUtilityPattern.MatchString(trimmed) {
|
||||
return &shellIssue{
|
||||
Kind: "windows_shell_syntax",
|
||||
Message: "Command uses a POSIX utility (head/tail/grep/wc/awk/sed/cut/xargs/tr) that Windows cmd.exe does not have.",
|
||||
Suggestion: "Use cmd.exe equivalents (e.g. `findstr` for grep, `more` to page output) or Zero's native tools (grep, read_file with offset/limit) instead of piping to a POSIX utility.",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
func TestParseBindingLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string // expected Label()
|
||||
}{
|
||||
{"option+o", "Alt+O"},
|
||||
{"ctrl+o", "Ctrl+O"},
|
||||
{"ctrl+e", "Ctrl+E"},
|
||||
{"option+b", "Alt+B"},
|
||||
{"option+O", "Alt+O"},
|
||||
{"alt+o", "Alt+O"},
|
||||
{"cmd+o", "Cmd+O"},
|
||||
{"super+o", "Cmd+O"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
p := parseBinding(tt.input)
|
||||
got := p.Label()
|
||||
if got != tt.want {
|
||||
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesOptionO(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Simulate iTerm2 with "Option as Meta" pressing Option+O
|
||||
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
|
||||
}
|
||||
|
||||
// Should NOT match plain 'o'
|
||||
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if matcher(msg2) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
|
||||
}
|
||||
|
||||
// Should NOT match ctrl+o
|
||||
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if matcher(msg3) {
|
||||
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionOMatchesComposedCharacters(t *testing.T) {
|
||||
p := parseBinding("option+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
|
||||
if matcher(msg) {
|
||||
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlEMatchesDefault(t *testing.T) {
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
|
||||
|
||||
if !matcher(msg) {
|
||||
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
|
||||
// (Mod.Contains superset correctness for the ctrl-letter fast path).
|
||||
p := parseBinding("ctrl+e")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
|
||||
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
|
||||
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
|
||||
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
|
||||
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
|
||||
p := parseBinding("alt+o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
|
||||
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
|
||||
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("alt+o matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
|
||||
// A configured plain-key binding (no modifiers, e.g. "o") should only
|
||||
// match when Mod is exactly 0, not when a modifier is held.
|
||||
p := parseBinding("o")
|
||||
matcher := p.Matcher()
|
||||
|
||||
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
|
||||
if !matcher(plain) {
|
||||
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
msg tea.KeyMsg
|
||||
}{
|
||||
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
|
||||
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
|
||||
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
|
||||
}
|
||||
|
||||
for _, tt := range bad {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if matcher(tt.msg) {
|
||||
t.Errorf("'o' matcher should NOT match %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBindingUTF8Character(t *testing.T) {
|
||||
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
|
||||
// not be rejected by a byte-length check.
|
||||
p := parseBinding("é")
|
||||
if p.isZero() {
|
||||
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
|
||||
}
|
||||
if p.code != 'é' {
|
||||
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
|
||||
}
|
||||
if p.text != "" {
|
||||
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
|
||||
}
|
||||
|
||||
// Also verify the matcher works
|
||||
matcher := p.Matcher()
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
|
||||
if !matcher(msg) {
|
||||
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
|
||||
}
|
||||
|
||||
// Should not match with a modifier
|
||||
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
|
||||
if matcher(msgAlt) {
|
||||
t.Errorf("'é' matcher should NOT match with modifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
|
||||
// Integration: model.keyMatch with a configured binding must reject
|
||||
// modifier supersets.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
m := model{keyBindings: bindings}
|
||||
|
||||
// Ctrl+O should match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("keyMatch should match ctrl+o when configured")
|
||||
}
|
||||
|
||||
// Ctrl+Alt+O should NOT match
|
||||
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
|
||||
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigToBindingPipeline(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleMouse: "ctrl+e",
|
||||
CycleReasoning: "ctrl+t",
|
||||
TogglePlan: "ctrl+p",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
binding parsedBinding
|
||||
wantKey string
|
||||
}{
|
||||
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
|
||||
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
|
||||
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
|
||||
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
|
||||
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.binding.isZero() {
|
||||
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
|
||||
}
|
||||
if got := tt.binding.Label(); got != tt.wantKey {
|
||||
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpBindingSubstitution(t *testing.T) {
|
||||
// Verify that the help overlay correctly shows user-configured bindings
|
||||
// by checking that Label() returns non-empty for config-sourced bindings.
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
|
||||
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
|
||||
}
|
||||
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
|
||||
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchOptionO is an integration-style test: it verifies that pressing
|
||||
// Option+O dispatches as toggleDetailed when configured.
|
||||
func TestDispatchOptionO(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
}
|
||||
bindings := resolveKeyBindings(cfg)
|
||||
|
||||
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
|
||||
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
|
||||
defaultFn := func(tea.KeyMsg) bool { return false }
|
||||
|
||||
// This is the dispatch check the model uses
|
||||
m := model{keyBindings: bindings}
|
||||
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
|
||||
t.Errorf("model.keyMatch should match option+o")
|
||||
}
|
||||
|
||||
// Also verify default doesn't match
|
||||
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
|
||||
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
|
||||
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
|
||||
}
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func TestContextCommandCardHandlesNilRegistryAndStableStyle(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Context",
|
||||
"0 tools",
|
||||
"style balanced",
|
||||
"style concise",
|
||||
"root unknown",
|
||||
} {
|
||||
assertContains(t, text, want)
|
||||
|
||||
@@ -559,6 +559,21 @@ func (m model) modelText(args string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// avgTurnLatencyText reports the session's rolling average turn wall-time for
|
||||
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
|
||||
// a turn has completed.
|
||||
func (m model) avgTurnLatencyText() string {
|
||||
if m.turnLatencyCount == 0 {
|
||||
return "n/a"
|
||||
}
|
||||
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
|
||||
if m.turnTTFTCount > 0 {
|
||||
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
|
||||
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
|
||||
}
|
||||
|
||||
func (m model) contextText() string {
|
||||
toolCount := len(m.registeredTools())
|
||||
return renderCommandCardTranscript(commandCard{
|
||||
@@ -578,6 +593,8 @@ func (m model) contextText() string {
|
||||
{Key: "effort", Value: m.effortDisplay()},
|
||||
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
|
||||
{Key: "usage", Value: m.usageSummaryText()},
|
||||
{Key: "cache", Value: m.cacheEfficiencyText()},
|
||||
{Key: "latency", Value: m.avgTurnLatencyText()},
|
||||
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -41,6 +41,8 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandNew
|
||||
commandFast
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -154,6 +156,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Clear the visible transcript.",
|
||||
kind: commandClear,
|
||||
},
|
||||
{
|
||||
name: "/new",
|
||||
usage: "/new",
|
||||
group: commandGroupSession,
|
||||
description: "Start a fresh session; the current one stays resumable via /resume.",
|
||||
kind: commandNew,
|
||||
},
|
||||
{
|
||||
name: "/search",
|
||||
aliases: []string{"/find"},
|
||||
@@ -227,6 +236,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or set reasoning effort for supported models.",
|
||||
kind: commandEffort,
|
||||
},
|
||||
{
|
||||
name: "/fast",
|
||||
usage: "/fast [on|off]",
|
||||
group: commandGroupModel,
|
||||
description: "Enable fast mode for the current model (/fast off to disable).",
|
||||
kind: commandFast,
|
||||
},
|
||||
{
|
||||
name: "/style",
|
||||
usage: "/style [list|balanced|concise|explanatory|review]",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
// fastPlan is the pure decision `/fast` makes before touching any state: either
|
||||
// a terminal message to show the user, or a target model to switch to.
|
||||
type fastPlan struct {
|
||||
message string // terminal notice; shown as-is when targetID is empty
|
||||
targetID string // model to switch to ("" means no switch — show message)
|
||||
toFast bool // the target is a fast model (drives the ⚡ prefix)
|
||||
}
|
||||
|
||||
// planFastCommand resolves what `/fast [on|off]` should do for the current model
|
||||
// using only the registry — no side effects. State is DERIVED from the model
|
||||
// identity: the session is "in fast mode" iff the current model has a base
|
||||
// variant. Returns a terminal message, or a target model to switch to.
|
||||
func planFastCommand(registry modelregistry.Registry, currentModel, arg string) fastPlan {
|
||||
// 1. Parse the argument (case-insensitive): no arg / "on" => enable fast,
|
||||
// "off" => return to base, anything else => usage error.
|
||||
enable := true
|
||||
switch strings.ToLower(strings.TrimSpace(arg)) {
|
||||
case "", "on":
|
||||
enable = true
|
||||
case "off":
|
||||
enable = false
|
||||
default:
|
||||
return fastPlan{message: fmt.Sprintf("⚡ /fast takes on or off, not %q", strings.TrimSpace(arg))}
|
||||
}
|
||||
|
||||
// 2. Read the current model.
|
||||
current := strings.TrimSpace(currentModel)
|
||||
if current == "" {
|
||||
return fastPlan{message: "⚡ Pick a model first · /model"}
|
||||
}
|
||||
short := modelShortName(registry, current)
|
||||
|
||||
// 3. Derived state: in fast mode iff the current model has a base variant.
|
||||
_, isFast := registry.BaseVariant(current)
|
||||
|
||||
// 4. No-op cases — already in the requested state.
|
||||
if enable && isFast {
|
||||
return fastPlan{message: "⚡ Already in the fast lane · " + short}
|
||||
}
|
||||
if !enable && !isFast {
|
||||
return fastPlan{message: "Already on base · " + short}
|
||||
}
|
||||
|
||||
// 5. Resolve the target variant.
|
||||
var target modelregistry.ModelEntry
|
||||
var ok bool
|
||||
if enable {
|
||||
target, ok = registry.FastVariant(current)
|
||||
} else {
|
||||
target, ok = registry.BaseVariant(current)
|
||||
}
|
||||
if !ok {
|
||||
return fastPlan{message: "⚡ No fast lane for " + short}
|
||||
}
|
||||
|
||||
// Switching TO a fast model iff the target itself has a base variant.
|
||||
_, toFast := registry.BaseVariant(target.ID)
|
||||
return fastPlan{targetID: target.ID, toFast: toFast}
|
||||
}
|
||||
|
||||
// handleFastCommand implements `/fast [on|off]`: toggle the session model between
|
||||
// its base and fast variant within the same family. The actual switch is
|
||||
// delegated to handleModelCommand — the same path /model uses — so provider
|
||||
// rebuild, the compaction-before-switch guard, and persistence all apply; this
|
||||
// never mutates model state directly.
|
||||
func (m model) handleFastCommand(arg string) (model, string) {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return m, "Failed to load the model registry: " + err.Error()
|
||||
}
|
||||
|
||||
plan := planFastCommand(registry, m.modelName, arg)
|
||||
if plan.targetID == "" {
|
||||
return m, plan.message
|
||||
}
|
||||
|
||||
var switchText string
|
||||
m, switchText = m.handleModelCommand(plan.targetID)
|
||||
if m.modelName != plan.targetID {
|
||||
// The switch did not complete — either an error, or a "compact first"
|
||||
// guard prompt from handleModelCommand. Surface its own message so the
|
||||
// user sees the real reason instead of a misleading "Switched to ...".
|
||||
return m, switchText
|
||||
}
|
||||
|
||||
short := modelShortName(registry, plan.targetID)
|
||||
if plan.toFast {
|
||||
return m, "⚡ Fast lane on · " + short
|
||||
}
|
||||
return m, "Fast lane off · " + short
|
||||
}
|
||||
|
||||
// modelShortName returns a model's short display name, falling back to the id
|
||||
// when the model is unknown or has no display name.
|
||||
func modelShortName(registry modelregistry.Registry, id string) string {
|
||||
if entry, ok := registry.Get(id); ok {
|
||||
if name := strings.TrimSpace(entry.DisplayName); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(id)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func fastTestRegistry(t *testing.T) modelregistry.Registry {
|
||||
t.Helper()
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry() error = %v", err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
// planFastCommand is the pure decision layer — every argument/state case is
|
||||
// exercised here without touching the switch machinery.
|
||||
func TestPlanFastCommand(t *testing.T) {
|
||||
registry := fastTestRegistry(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
model string
|
||||
arg string
|
||||
wantMessage string // substring expected when the plan is terminal
|
||||
wantTarget string // model id expected when the plan switches
|
||||
wantToFast bool
|
||||
}{
|
||||
{name: "invalid arg", model: "gpt-4.1", arg: "turbo", wantMessage: `/fast takes on or off, not "turbo"`},
|
||||
{name: "no model set", model: "", arg: "on", wantMessage: "Pick a model first"},
|
||||
{name: "enable while already fast", model: "gpt-4.1-mini", arg: "on", wantMessage: "Already in the fast lane"},
|
||||
{name: "disable while already base", model: "gpt-4.1", arg: "off", wantMessage: "Already on base"},
|
||||
{name: "enable with no fast variant", model: "claude-opus-4.1", arg: "on", wantMessage: "No fast lane"},
|
||||
{name: "enable (no arg) resolves fast", model: "gpt-4.1", arg: "", wantTarget: "gpt-4.1-mini", wantToFast: true},
|
||||
{name: "enable (on) resolves fast", model: "gpt-4.1", arg: "ON", wantTarget: "gpt-4.1-mini", wantToFast: true},
|
||||
{name: "disable resolves base", model: "gpt-4.1-mini", arg: "off", wantTarget: "gpt-4.1", wantToFast: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := planFastCommand(registry, tc.model, tc.arg)
|
||||
if tc.wantTarget != "" {
|
||||
canon, ok := registry.Resolve(tc.wantTarget)
|
||||
if !ok {
|
||||
t.Fatalf("test fixture %q does not resolve", tc.wantTarget)
|
||||
}
|
||||
if plan.targetID != canon.ID {
|
||||
t.Fatalf("targetID = %q, want %q", plan.targetID, canon.ID)
|
||||
}
|
||||
if plan.toFast != tc.wantToFast {
|
||||
t.Fatalf("toFast = %v, want %v", plan.toFast, tc.wantToFast)
|
||||
}
|
||||
if plan.message != "" {
|
||||
t.Fatalf("switching plan should carry no message, got %q", plan.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
if plan.targetID != "" {
|
||||
t.Fatalf("expected a terminal message, got target %q", plan.targetID)
|
||||
}
|
||||
if !strings.Contains(plan.message, tc.wantMessage) {
|
||||
t.Fatalf("message = %q, want it to contain %q", plan.message, tc.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleFastCommand's terminal cases return the plan message without switching,
|
||||
// so a bare model exercises them.
|
||||
func TestHandleFastCommandTerminalCases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
model string
|
||||
arg string
|
||||
wantContains string
|
||||
}{
|
||||
{"invalid arg", "gpt-4.1", "sideways", `/fast takes on or off, not "sideways"`},
|
||||
{"no model", "", "on", "Pick a model first"},
|
||||
{"already fast", "gpt-4.1-mini", "on", "Already in the fast lane"},
|
||||
{"already base", "gpt-4.1", "off", "Already on base"},
|
||||
{"no fast variant", "claude-opus-4.1", "on", "No fast lane"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := model{modelName: tc.model}
|
||||
got, notice := m.handleFastCommand(tc.arg)
|
||||
if got.modelName != tc.model {
|
||||
t.Fatalf("model must not change on a terminal case: %q -> %q", tc.model, got.modelName)
|
||||
}
|
||||
if !strings.Contains(notice, tc.wantContains) {
|
||||
t.Fatalf("notice = %q, want it to contain %q", notice, tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newFastTestModel(t *testing.T, modelID string) model {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(t.TempDir(), "zero.json")
|
||||
profile := config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
APIKey: "sk-test",
|
||||
Model: modelID,
|
||||
}
|
||||
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
|
||||
t.Fatalf("write user config: %v", err)
|
||||
}
|
||||
return newModel(context.Background(), Options{
|
||||
UserConfigPath: configPath,
|
||||
ProviderName: "openai",
|
||||
ModelName: modelID,
|
||||
ProviderProfile: profile,
|
||||
Provider: &fakeProvider{},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Enabling switches base -> fast with a ⚡ notice; disabling switches back to the
|
||||
// base with a plain notice. Uses the same switch path /model uses.
|
||||
func TestHandleFastCommandSwitchesToFastAndBack(t *testing.T) {
|
||||
registry := fastTestRegistry(t)
|
||||
m := newFastTestModel(t, "gpt-4.1")
|
||||
|
||||
m, notice := m.handleFastCommand("on")
|
||||
if m.modelName != "gpt-4.1-mini" {
|
||||
t.Fatalf("enable: modelName = %q, want gpt-4.1-mini", m.modelName)
|
||||
}
|
||||
if want := "⚡ Fast lane on · " + modelShortName(registry, "gpt-4.1-mini"); notice != want {
|
||||
t.Fatalf("enable notice = %q, want exactly %q (bolt + fast-lane-on + target short name)", notice, want)
|
||||
}
|
||||
|
||||
m, notice = m.handleFastCommand("off")
|
||||
if m.modelName != "gpt-4.1" {
|
||||
t.Fatalf("disable: modelName = %q, want gpt-4.1", m.modelName)
|
||||
}
|
||||
if want := "Fast lane off · " + modelShortName(registry, "gpt-4.1"); notice != want {
|
||||
t.Fatalf("disable notice = %q, want exactly %q (plain fast-lane-off, no bolt)", notice, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-op and no-variant notices must carry their full spec decoration — the ⚡
|
||||
// prefix and the (<short>) name — so a regression dropping either fails loudly
|
||||
// rather than passing a bare substring assertion.
|
||||
func TestFastCommandNoOpAndNoVariantMessagesAreDecorated(t *testing.T) {
|
||||
registry := fastTestRegistry(t)
|
||||
|
||||
plan := planFastCommand(registry, "gpt-4.1-mini", "on")
|
||||
if want := "⚡ Already in the fast lane · " + modelShortName(registry, "gpt-4.1-mini"); plan.message != want {
|
||||
t.Fatalf("already-fast message = %q, want %q", plan.message, want)
|
||||
}
|
||||
|
||||
plan = planFastCommand(registry, "gpt-4.1", "off")
|
||||
if want := "Already on base · " + modelShortName(registry, "gpt-4.1"); plan.message != want {
|
||||
t.Fatalf("already-base message = %q, want %q", plan.message, want)
|
||||
}
|
||||
|
||||
// An unknown / off-catalog model id also reaches the no-variant branch, where
|
||||
// <short> falls back to the raw id.
|
||||
plan = planFastCommand(registry, "some-custom-live-provider-model", "on")
|
||||
if want := "⚡ No fast lane for some-custom-live-provider-model"; plan.message != want {
|
||||
t.Fatalf("no-variant (unknown id) message = %q, want %q", plan.message, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the underlying switch fails, /fast surfaces the error and leaves the
|
||||
// model unchanged (here: a valid base model but no provider profile to rebuild).
|
||||
func TestHandleFastCommandSurfacesSwitchError(t *testing.T) {
|
||||
m := model{modelName: "gpt-4.1"} // no providerProfile / newProvider wired
|
||||
got, notice := m.handleFastCommand("on")
|
||||
if got.modelName != "gpt-4.1" {
|
||||
t.Fatalf("model must be unchanged on switch failure, got %q", got.modelName)
|
||||
}
|
||||
if strings.HasPrefix(notice, "⚡ Fast lane on ") || strings.HasPrefix(notice, "Fast lane off ") {
|
||||
t.Fatalf("a failed switch must not claim success, got %q", notice)
|
||||
}
|
||||
if strings.TrimSpace(notice) == "" {
|
||||
t.Fatal("a failed switch must surface a reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelShortName(t *testing.T) {
|
||||
registry := fastTestRegistry(t)
|
||||
if got := modelShortName(registry, "gpt-4.1"); strings.TrimSpace(got) == "" {
|
||||
t.Fatal("a known model should resolve to a non-empty short name")
|
||||
}
|
||||
if got := modelShortName(registry, "totally-unknown-model-xyz"); got != "totally-unknown-model-xyz" {
|
||||
t.Fatalf("an unknown model short name = %q, want the raw id", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
// fastMarkerGlyph is the compact "f" badge shown next to a model that has an
|
||||
// available fast variant — i.e. one where `/fast on` would switch down to a
|
||||
// faster same-family model. It marks the BASE model, never the fast one.
|
||||
const fastMarkerGlyph = "f"
|
||||
|
||||
// fastVariantEntryFor resolves the fast variant of an arbitrary model id, if any.
|
||||
// Like modelContextWindow, it reads the default registry on demand rather than
|
||||
// caching derived state, so a marker can never go stale after a model switch.
|
||||
// DefaultRegistry is memoized, so even the per-frame callers (the active-model
|
||||
// chip renders every frame; the pickers render per keystroke) pay only a map
|
||||
// lookup here, not a registry rebuild.
|
||||
func fastVariantEntryFor(id string) (modelregistry.ModelEntry, bool) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return modelregistry.ModelEntry{}, false
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return modelregistry.ModelEntry{}, false
|
||||
}
|
||||
return registry.FastVariant(id)
|
||||
}
|
||||
|
||||
// currentModelFastVariant resolves the fast variant of the session's active model.
|
||||
func (m model) currentModelFastVariant() (modelregistry.ModelEntry, bool) {
|
||||
return fastVariantEntryFor(m.modelName)
|
||||
}
|
||||
|
||||
// renderFastBadge renders the "f" fast-mode marker in the given (surface-aware)
|
||||
// style, prefixed with a space so it reads as a distinct badge rather than a
|
||||
// trailing letter of the adjacent model name.
|
||||
func renderFastBadge(style lipgloss.Style) string {
|
||||
return " " + style.Render(fastMarkerGlyph)
|
||||
}
|
||||
|
||||
// fastVariantHint is the one-line reveal shown against a focused/selected
|
||||
// fast-capable model. The TUI has no floating-tooltip primitive, so the "hover"
|
||||
// affordance is this inline detail on the active row: it names the target model
|
||||
// and the command that gets there.
|
||||
func fastVariantHint(variantName string) string {
|
||||
variantName = strings.TrimSpace(variantName)
|
||||
if variantName == "" {
|
||||
return ""
|
||||
}
|
||||
return "⚡ fast: " + variantName + " · /fast on"
|
||||
}
|
||||
|
||||
// fastToastDuration is how long a /fast toast stays up before auto-dismissing.
|
||||
const fastToastDuration = 3 * time.Second
|
||||
|
||||
// fastToastExpiredMsg clears a fast-lane toast after fastToastDuration. It is
|
||||
// seq-gated so a newer toast is never dismissed by an older timer.
|
||||
type fastToastExpiredMsg struct{ seq int }
|
||||
|
||||
// showFastToast raises a transient fast-lane notice in the row above the composer
|
||||
// and schedules its dismissal. It replaces the persistent transcript line the
|
||||
// /fast command used to append, so repeated toggles never pile up in history.
|
||||
func (m model) showFastToast(text string) (model, tea.Cmd) {
|
||||
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
|
||||
if text == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.fastToastSeq++
|
||||
m.fastToast = text
|
||||
seq := m.fastToastSeq
|
||||
return m, tea.Tick(fastToastDuration, func(time.Time) tea.Msg {
|
||||
return fastToastExpiredMsg{seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
// renderFastToast styles the toast as a filled brand pill so it reads as a
|
||||
// transient popup rather than a permanent status line.
|
||||
func renderFastToast(text string) string {
|
||||
return zeroTheme.badge.Render(" " + strings.TrimSpace(text) + " ")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
)
|
||||
|
||||
// hasFastBadge reports whether a rendered (styled) line ends with the " f"
|
||||
// fast-mode badge, ignoring trailing layout padding. Labels used in these tests
|
||||
// deliberately contain no trailing " f" so the badge is unambiguous.
|
||||
func hasFastBadge(rendered string) bool {
|
||||
return strings.HasSuffix(strings.TrimRight(ansi.Strip(rendered), " "), " "+fastMarkerGlyph)
|
||||
}
|
||||
|
||||
func TestFastVariantHint(t *testing.T) {
|
||||
if got := fastVariantHint("Claude Haiku 4.5"); got != "⚡ fast: Claude Haiku 4.5 · /fast on" {
|
||||
t.Errorf("fastVariantHint = %q", got)
|
||||
}
|
||||
if got := fastVariantHint(" "); got != "" {
|
||||
t.Errorf("fastVariantHint(blank) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFastBadge(t *testing.T) {
|
||||
if got := ansi.Strip(renderFastBadge(zeroTheme.accent)); got != " "+fastMarkerGlyph {
|
||||
t.Errorf("renderFastBadge visible text = %q, want %q", got, " "+fastMarkerGlyph)
|
||||
}
|
||||
}
|
||||
|
||||
// The active-model chip carries the "f" badge only when the current model has a
|
||||
// fast variant — base models yes, fast models and unpaired top-tier models no.
|
||||
func TestTitleModelSegmentFastBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"gpt-4.1", true}, // base with a fast variant
|
||||
{"gpt-4.1-mini", false}, // the fast variant itself has none
|
||||
{"claude-opus-4.1", false}, // top-tier, intentionally unpaired
|
||||
{"definitely-not-real", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
seg := model{modelName: tc.model}.titleModelSegment()
|
||||
if got := hasFastBadge(seg); got != tc.want {
|
||||
t.Errorf("titleModelSegment(%q) badge = %v, want %v (visible %q)", tc.model, got, tc.want, ansi.Strip(seg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A /model picker row shows the badge iff its FastVariant field is populated,
|
||||
// and the field is populated only for base models by decorateFastVariantItems.
|
||||
func TestRenderModelPickerRowFastBadge(t *testing.T) {
|
||||
withFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
|
||||
if !hasFastBadge(withFast) {
|
||||
t.Errorf("fast-capable row missing badge: %q", ansi.Strip(withFast))
|
||||
}
|
||||
withoutFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1"})
|
||||
if hasFastBadge(withoutFast) {
|
||||
t.Errorf("row without FastVariant should have no badge: %q", ansi.Strip(withoutFast))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPickerItemDetailIncludesFastHint(t *testing.T) {
|
||||
detail := modelPickerItemDetail(pickerItem{Label: "GPT-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
|
||||
if !strings.Contains(detail, "⚡ fast: GPT-4.1 mini · /fast on") {
|
||||
t.Errorf("detail missing fast hint: %q", detail)
|
||||
}
|
||||
plain := modelPickerItemDetail(pickerItem{Label: "Claude Opus 4.1", Value: "claude-opus-4.1"})
|
||||
if strings.Contains(plain, "fast:") {
|
||||
t.Errorf("non-fast detail should have no hint: %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
// decorateFastVariantItems stamps the fast variant's display name onto base-model
|
||||
// rows only, using the real registry — the single source of truth for the badge.
|
||||
func TestDecorateFastVariantItems(t *testing.T) {
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRegistry: %v", err)
|
||||
}
|
||||
items := []pickerItem{
|
||||
{Value: "gpt-4.1"}, // base -> GPT-4.1 mini
|
||||
{Value: "gpt-4.1-mini"}, // fast, no variant
|
||||
{Value: "claude-opus-4.1"}, // unpaired
|
||||
{Value: "some-custom-model"}, // not in registry
|
||||
{Value: ""},
|
||||
}
|
||||
decorateFastVariantItems(registry, items)
|
||||
if items[0].FastVariant != "GPT-4.1 mini" {
|
||||
t.Errorf("gpt-4.1 FastVariant = %q, want %q", items[0].FastVariant, "GPT-4.1 mini")
|
||||
}
|
||||
for _, i := range []int{1, 2, 3, 4} {
|
||||
if items[i].FastVariant != "" {
|
||||
t.Errorf("items[%d].FastVariant = %q, want empty", i, items[i].FastVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowFastToast(t *testing.T) {
|
||||
// A non-empty message raises the toast, bumps the seq, and schedules dismissal.
|
||||
m, cmd := model{}.showFastToast("⚡ Fast lane on · GPT-4.1 mini")
|
||||
if m.fastToast != "⚡ Fast lane on · GPT-4.1 mini" {
|
||||
t.Fatalf("fastToast = %q", m.fastToast)
|
||||
}
|
||||
if m.fastToastSeq != 1 {
|
||||
t.Fatalf("fastToastSeq = %d, want 1", m.fastToastSeq)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("showFastToast must return a dismissal tick cmd")
|
||||
}
|
||||
|
||||
// A multi-line switch-guard message is flattened so the toast stays one line
|
||||
// (the footer slot is fixed-height).
|
||||
m2, _ := model{}.showFastToast("Model\nCannot switch while a run is active.")
|
||||
if strings.Contains(m2.fastToast, "\n") {
|
||||
t.Fatalf("toast must be flattened to one line, got %q", m2.fastToast)
|
||||
}
|
||||
|
||||
// Blank text is a no-op: no toast, no timer.
|
||||
m3, cmd3 := model{}.showFastToast(" ")
|
||||
if m3.fastToast != "" || cmd3 != nil {
|
||||
t.Fatalf("blank text should not raise a toast, got %q / cmd!=nil=%v", m3.fastToast, cmd3 != nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderFastToastShowsMessage(t *testing.T) {
|
||||
got := ansi.Strip(renderFastToast("⚡ No fast lane for gpt-5.5"))
|
||||
if !strings.Contains(got, "⚡ No fast lane for gpt-5.5") {
|
||||
t.Fatalf("rendered toast = %q, want it to contain the message", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderWizardModelFastBadgeAndHint(t *testing.T) {
|
||||
wizard := &providerWizardState{selectedModel: 0}
|
||||
withFast := wizard.renderSelectableModel(60, 0, providerWizardModel{ID: "gpt-4.1"})
|
||||
if !hasFastBadge(withFast) {
|
||||
t.Errorf("provider wizard base-model row missing badge: %q", ansi.Strip(withFast))
|
||||
}
|
||||
plain := wizard.renderSelectableModel(60, 1, providerWizardModel{ID: "claude-opus-4.1"})
|
||||
if hasFastBadge(plain) {
|
||||
t.Errorf("provider wizard unpaired-model row should have no badge: %q", ansi.Strip(plain))
|
||||
}
|
||||
if detail := providerWizardModelDetail(providerWizardModel{ID: "gpt-4.1"}); !strings.Contains(detail, "⚡ fast:") {
|
||||
t.Errorf("provider wizard detail missing fast hint: %q", detail)
|
||||
}
|
||||
}
|
||||
@@ -105,11 +105,14 @@ func TestClearResetsFlushFrontier(t *testing.T) {
|
||||
m.input.SetValue("/clear")
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed > 1 {
|
||||
t.Fatalf("expected frontier reset after /clear, got %d", next.flushed)
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected /clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
if next.flushed != len(next.transcript) {
|
||||
t.Fatalf("expected frontier reset to match cleared transcript (%d rows), got %d", len(next.transcript), next.flushed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHumanCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{0, "0"},
|
||||
{999, "999"},
|
||||
{1000, "1K"},
|
||||
{12400, "12.4K"},
|
||||
{200000, "200K"},
|
||||
{999999, "1000K"},
|
||||
// The bug: a million-scale count must switch to an M suffix instead
|
||||
// of continuing to render as "1000K", "1200K", etc.
|
||||
{1_000_000, "1M"},
|
||||
{1_048_576, "1M"},
|
||||
{1_200_000, "1.2M"},
|
||||
{-5, "0"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := humanCount(c.n); got != c.want {
|
||||
t.Errorf("humanCount(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
// lists them grouped, so the keymap is discoverable the way the reference TUIs
|
||||
// do it. The list is declarative and hand-curated to match the real handlers in
|
||||
// model.go's Update switch; keep them in sync when a binding changes.
|
||||
//
|
||||
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
|
||||
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
|
||||
// who remaps them in config.json sees the actual chords, not the defaults.
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
@@ -21,63 +25,67 @@ type keybindingGroup struct {
|
||||
bindings []keybinding
|
||||
}
|
||||
|
||||
// keybindingGroups is the full, grouped shortcut list shown by the `?` overlay.
|
||||
// buildKeybindingGroups returns the full, grouped shortcut list shown by the
|
||||
// `?` overlay. Configurable bindings use the user's remapped labels (via
|
||||
// m.keyBindings) when present; unconfigured bindings get their built-in labels.
|
||||
// Sourced from the real key cases in model.go (Update) — not invented. When a
|
||||
// binding is added/changed there, update it here too (a test guards that the
|
||||
// data is non-empty and well-formed, but it can't verify the bindings exist).
|
||||
var keybindingGroups = []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
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"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
// binding is added/changed there, update this method too.
|
||||
func (m model) buildKeybindingGroups() []keybindingGroup {
|
||||
return []keybindingGroup{
|
||||
{
|
||||
title: "Chat",
|
||||
bindings: []keybinding{
|
||||
{"Enter", "send the message"},
|
||||
{"Alt+Enter", "insert a newline (multi-line compose)"},
|
||||
{"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"},
|
||||
{"Ctrl+C", "cancel the run, then quit"},
|
||||
{"?", "show this help (on an empty input)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model & run controls",
|
||||
bindings: []keybinding{
|
||||
{"Ctrl+T", "cycle reasoning effort (auto → low → medium → high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto ↔ ask)"},
|
||||
{"Ctrl+P", "expand / collapse the plan panel"},
|
||||
{
|
||||
title: "Model & run controls",
|
||||
bindings: []keybinding{
|
||||
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
|
||||
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
|
||||
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Navigation & scrollback",
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"↑ / ↓", "scroll, or move within a popup / multi-line input"},
|
||||
{"Ctrl+O", "toggle the detailed (full-screen) transcript"},
|
||||
{"Ctrl+B", "hide / show the right context sidebar"},
|
||||
{"Ctrl+E", "release the mouse to drag-select & copy text"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
{
|
||||
title: "Navigation & scrollback",
|
||||
bindings: []keybinding{
|
||||
{"PgUp / PgDn", "scroll the transcript by a page"},
|
||||
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
|
||||
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
|
||||
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
|
||||
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
|
||||
{"Tab", "accept the autocomplete / picker selection"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Specialists & pickers",
|
||||
bindings: []keybinding{
|
||||
{"Click a specialist card", "drill into its sub-session"},
|
||||
{"↑ / Esc (in a sub-session)", "return to the main chat"},
|
||||
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
|
||||
{"Click a tool card", "expand / collapse its output"},
|
||||
{"Right-click", "paste the clipboard"},
|
||||
{
|
||||
title: "Specialists & pickers",
|
||||
bindings: []keybinding{
|
||||
{"Click a specialist card", "drill into its sub-session"},
|
||||
{"\u2191 / Esc (in a sub-session)", "return to the main chat"},
|
||||
{"Ctrl+F (in /model)", "toggle the highlighted model as a favorite"},
|
||||
{"Click a tool card", "expand / collapse its output"},
|
||||
{"Right-click", "paste the clipboard"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// keybindingHelpFooter is the dismiss hint shown at the bottom of the overlay.
|
||||
const keybindingHelpFooter = "? or Esc to close · /help for slash commands"
|
||||
const keybindingHelpFooter = "? or Esc to close \u00b7 /help for slash commands"
|
||||
|
||||
// renderKeybindingHelpLines builds the overlay body lines (group titles +
|
||||
// aligned key/description rows + footer), wrapped to the inner width. Exposed
|
||||
// separately from the framed renderer so tests can assert on content without
|
||||
// the border chrome.
|
||||
func (m model) renderKeybindingHelpLines(innerWidth int) []string {
|
||||
keyColumn := keybindingKeyColumnWidth()
|
||||
groups := m.buildKeybindingGroups()
|
||||
keyColumn := keybindingKeyColumnWidth(groups)
|
||||
lines := make([]string, 0, 64)
|
||||
for index, group := range keybindingGroups {
|
||||
for index, group := range groups {
|
||||
if index > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
@@ -93,9 +101,9 @@ func (m model) renderKeybindingHelpLines(innerWidth int) []string {
|
||||
|
||||
// keybindingKeyColumnWidth returns the width of the key column: the widest key
|
||||
// chord across all groups, so the descriptions align in a clean second column.
|
||||
func keybindingKeyColumnWidth() int {
|
||||
func keybindingKeyColumnWidth(groups []keybindingGroup) int {
|
||||
widest := 0
|
||||
for _, group := range keybindingGroups {
|
||||
for _, group := range groups {
|
||||
for _, binding := range group.bindings {
|
||||
if n := len([]rune(binding.keys)); n > widest {
|
||||
widest = n
|
||||
@@ -124,9 +132,9 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
|
||||
return " " + keyCell + " " + desc
|
||||
}
|
||||
|
||||
// renderKeybindingHelpOverlay renders the framed, centered `?` help overlay for
|
||||
// the given terminal dimensions.
|
||||
func (m model) renderKeybindingHelpOverlay(width int, height int) string {
|
||||
// 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 {
|
||||
overlayWidth := keybindingHelpOverlayWidth(width)
|
||||
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
|
||||
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
|
||||
|
||||
@@ -87,11 +87,13 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeybindingGroupsAreWellFormed(t *testing.T) {
|
||||
if len(keybindingGroups) == 0 {
|
||||
t.Fatal("keybindingGroups must not be empty")
|
||||
func TestBuildKeybindingGroupsAreWellFormed(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
|
||||
groups := m.buildKeybindingGroups()
|
||||
if len(groups) == 0 {
|
||||
t.Fatal("buildKeybindingGroups must not return empty")
|
||||
}
|
||||
for _, group := range keybindingGroups {
|
||||
for _, group := range groups {
|
||||
if strings.TrimSpace(group.title) == "" {
|
||||
t.Fatal("every keybinding group needs a title")
|
||||
}
|
||||
@@ -105,3 +107,55 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
)
|
||||
|
||||
// parsedBinding is the parsed representation of a keybinding string such as
|
||||
// "ctrl+o" or "alt+enter". The zero value is a sentinel meaning "use default".
|
||||
type parsedBinding struct {
|
||||
ctrl bool
|
||||
alt bool
|
||||
shift bool
|
||||
cmd bool // ⌘ Command (macOS) / ⊞ Win → ModSuper
|
||||
code rune // tea.KeyCode or 0 for text-based matching
|
||||
text string // for text-based matching (e.g. "?")
|
||||
}
|
||||
|
||||
// isZero returns true when p is the nil sentinel (no binding configured).
|
||||
func (p parsedBinding) isZero() bool {
|
||||
return p.code == 0 && p.text == ""
|
||||
}
|
||||
|
||||
// Label returns a human-readable representation of the binding, e.g. "Ctrl+O"
|
||||
// or "Cmd+Shift+Enter". Used in the help overlay. Returns empty string for
|
||||
// zero (unset) bindings.
|
||||
func (p parsedBinding) Label() string {
|
||||
if p.isZero() {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
if p.ctrl {
|
||||
b.WriteString("Ctrl+")
|
||||
}
|
||||
if p.alt {
|
||||
b.WriteString("Alt+")
|
||||
}
|
||||
if p.shift {
|
||||
b.WriteString("Shift+")
|
||||
}
|
||||
if p.cmd {
|
||||
b.WriteString("Cmd+")
|
||||
}
|
||||
if p.text != "" {
|
||||
b.WriteString(p.text)
|
||||
} else if p.code != 0 {
|
||||
switch p.code {
|
||||
case tea.KeyEnter:
|
||||
b.WriteString("Enter")
|
||||
case tea.KeyTab:
|
||||
b.WriteString("Tab")
|
||||
case tea.KeyEsc:
|
||||
b.WriteString("Esc")
|
||||
case tea.KeySpace:
|
||||
b.WriteString("Space")
|
||||
case tea.KeyBackspace:
|
||||
b.WriteString("Backspace")
|
||||
case tea.KeyDelete:
|
||||
b.WriteString("Delete")
|
||||
case tea.KeyUp:
|
||||
b.WriteString("↑")
|
||||
case tea.KeyDown:
|
||||
b.WriteString("↓")
|
||||
case tea.KeyLeft:
|
||||
b.WriteString("←")
|
||||
case tea.KeyRight:
|
||||
b.WriteString("→")
|
||||
case tea.KeyHome:
|
||||
b.WriteString("Home")
|
||||
case tea.KeyEnd:
|
||||
b.WriteString("End")
|
||||
case tea.KeyPgUp:
|
||||
b.WriteString("PgUp")
|
||||
case tea.KeyPgDown:
|
||||
b.WriteString("PgDn")
|
||||
default:
|
||||
// Printable character — uppercase for display
|
||||
if p.code >= 'a' && p.code <= 'z' {
|
||||
b.WriteRune(p.code - 32)
|
||||
} else {
|
||||
b.WriteRune(p.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Matcher returns a function that matches a tea.KeyMsg against this binding.
|
||||
// It is the hot path for the key dispatch — kept cheap intentionally.
|
||||
func (p parsedBinding) Matcher() func(tea.KeyMsg) bool {
|
||||
if p.isZero() {
|
||||
// A zero binding should never be matched — the caller is expected to
|
||||
// check useDefault() first and fall through to the built-in check.
|
||||
return func(tea.KeyMsg) bool { return false }
|
||||
}
|
||||
|
||||
// Build the required modifier mask from the parsed flags.
|
||||
var mod tea.KeyMod
|
||||
if p.ctrl {
|
||||
mod |= tea.ModCtrl
|
||||
}
|
||||
if p.alt {
|
||||
mod |= tea.ModAlt
|
||||
}
|
||||
if p.shift {
|
||||
mod |= tea.ModShift
|
||||
}
|
||||
if p.cmd {
|
||||
mod |= tea.ModSuper // ⌘ Command on macOS, ⊞ Win on Windows
|
||||
}
|
||||
|
||||
// ctrl+letter fast path — use exact modifier equality so that a
|
||||
// configured ctrl+o does NOT fire on ctrl+alt+o or ctrl+shift+o.
|
||||
// Note: in raw terminal mode Bubble Tea may report ctrl+letter as
|
||||
// {Code:letter, Mod:ModCtrl} (handled below), or as a control char
|
||||
// code (e.g. 0x0F) without a modifier flag. The latter is handled by
|
||||
// the text-based fallback in model.go for default bindings; for
|
||||
// configured bindings the code+mod path below is what the user
|
||||
// expressed.
|
||||
if mod == tea.ModCtrl && p.code >= 'a' && p.code <= 'z' {
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Code == p.code && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
if p.text != "" {
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Text == p.text && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
return func(msg tea.KeyMsg) bool {
|
||||
return msg.Key().Code == p.code && msg.Key().Mod == mod
|
||||
}
|
||||
}
|
||||
|
||||
// parseBinding converts a user-written keybinding string (e.g. "ctrl+o") into
|
||||
// a parsedBinding. The empty string returns zero parsedBinding (the "use
|
||||
// default" sentinel).
|
||||
func parseBinding(s string) parsedBinding {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return parsedBinding{}
|
||||
}
|
||||
|
||||
parts := strings.Split(strings.ToLower(s), "+")
|
||||
var p parsedBinding
|
||||
var keyPart string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
switch part {
|
||||
case "ctrl", "control":
|
||||
p.ctrl = true
|
||||
case "alt", "option":
|
||||
p.alt = true
|
||||
case "shift":
|
||||
p.shift = true
|
||||
case "cmd", "command":
|
||||
p.cmd = true
|
||||
case "super":
|
||||
// "super" matches the Bubble Tea naming; on macOS this is ⌘
|
||||
p.cmd = true
|
||||
default:
|
||||
keyPart = part
|
||||
}
|
||||
}
|
||||
|
||||
if p.ctrl && len(keyPart) == 1 && keyPart[0] >= 'a' && keyPart[0] <= 'z' {
|
||||
// ctrl+<letter> — store the code so the match is exact
|
||||
p.code = []rune(keyPart)[0]
|
||||
p.text = ""
|
||||
return p
|
||||
}
|
||||
|
||||
// Map named keys to their tea.KeyCode
|
||||
switch keyPart {
|
||||
case "enter", "return":
|
||||
p.code = tea.KeyEnter
|
||||
case "tab":
|
||||
p.code = tea.KeyTab
|
||||
case "esc", "escape":
|
||||
p.code = tea.KeyEsc
|
||||
case "space":
|
||||
p.code = tea.KeySpace
|
||||
case "backspace":
|
||||
p.code = tea.KeyBackspace
|
||||
case "delete":
|
||||
p.code = tea.KeyDelete
|
||||
case "up":
|
||||
p.code = tea.KeyUp
|
||||
case "down":
|
||||
p.code = tea.KeyDown
|
||||
case "left":
|
||||
p.code = tea.KeyLeft
|
||||
case "right":
|
||||
p.code = tea.KeyRight
|
||||
case "home":
|
||||
p.code = tea.KeyHome
|
||||
case "end":
|
||||
p.code = tea.KeyEnd
|
||||
case "pgup", "pageup":
|
||||
p.code = tea.KeyPgUp
|
||||
case "pgdown", "pagedown":
|
||||
p.code = tea.KeyPgDown
|
||||
case "?":
|
||||
p.text = "?"
|
||||
p.code = 0
|
||||
default:
|
||||
// Single character, any modifier context
|
||||
if utf8.RuneCountInString(keyPart) == 1 {
|
||||
p.code = []rune(keyPart)[0]
|
||||
}
|
||||
// else unrecognised — leave zero so it falls through to default
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// labelOr returns b.Label() when b is configured (non-zero), otherwise it
|
||||
// returns the caller-supplied default label string. This is the display-layer
|
||||
// counterpart to keyMatch — dispatch falls through to the hardcoded default
|
||||
// function, so the label displayed in help / hints must match that fallback.
|
||||
func labelOr(b parsedBinding, defaultLabel string) string {
|
||||
if !b.isZero() {
|
||||
return b.Label()
|
||||
}
|
||||
return defaultLabel
|
||||
}
|
||||
|
||||
// keyBindings holds the parsed, resolved key bindings the model uses at
|
||||
// dispatch time. When a binding's parsedBinding is zero, the built-in default
|
||||
// check in model.go's updateModel should be used.
|
||||
type keyBindings struct {
|
||||
toggleDetailed parsedBinding
|
||||
toggleMouse parsedBinding
|
||||
cycleReasoning parsedBinding
|
||||
togglePlan parsedBinding
|
||||
toggleSidebar parsedBinding
|
||||
}
|
||||
|
||||
// resolveKeyBindings converts a user-facing KeyBindingsConfig into the
|
||||
// dispatch-ready parsed form, using empty-is-default semantics.
|
||||
func resolveKeyBindings(cfg config.KeyBindingsConfig) keyBindings {
|
||||
return keyBindings{
|
||||
toggleDetailed: parseBinding(string(cfg.ToggleDetailed)),
|
||||
toggleMouse: parseBinding(string(cfg.ToggleMouse)),
|
||||
cycleReasoning: parseBinding(string(cfg.CycleReasoning)),
|
||||
togglePlan: parseBinding(string(cfg.TogglePlan)),
|
||||
toggleSidebar: parseBinding(string(cfg.ToggleSidebar)),
|
||||
}
|
||||
}
|
||||
|
||||
// keyMatch returns true when msg matches either the user-configured binding b
|
||||
// or (when b is zero/unset) the built-in default matcher defaultFn. This is
|
||||
// the bridge between the config surface and the hot dispatch path in model.go.
|
||||
func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyMsg) bool) bool {
|
||||
if !b.isZero() {
|
||||
return b.Matcher()(msg)
|
||||
}
|
||||
return defaultFn(msg)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
)
|
||||
|
||||
func TestAvgTurnLatencyText(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
if got := m.avgTurnLatencyText(); got != "n/a" {
|
||||
t.Fatalf("empty latency = %q, want n/a", got)
|
||||
}
|
||||
m.turnLatencySum = 9 * time.Second
|
||||
m.turnLatencyCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText = %q, want \"4.5s avg (2 turns)\"", got)
|
||||
}
|
||||
// With TTFT recorded, the line also reports time-to-first-token.
|
||||
m.turnTTFTSum = 3 * time.Second
|
||||
m.turnTTFTCount = 2
|
||||
if got := m.avgTurnLatencyText(); got != "4.5s avg (1.5s to first token, 2 turns)" {
|
||||
t.Fatalf("avgTurnLatencyText with ttft = %q, want \"4.5s avg (1.5s to first token, 2 turns)\"", got)
|
||||
}
|
||||
// /new must reset the rolling latency + ttft so a fresh session starts from zero.
|
||||
m.activeSession = sessions.Metadata{SessionID: "x"}
|
||||
next := m.startNewSession()
|
||||
if next.turnLatencyCount != 0 || next.turnLatencySum != 0 || next.turnTTFTCount != 0 || next.turnTTFTSum != 0 {
|
||||
t.Fatalf("startNewSession must reset latency+ttft, got latency=%d/%v ttft=%d/%v", next.turnLatencyCount, next.turnLatencySum, next.turnTTFTCount, next.turnTTFTSum)
|
||||
}
|
||||
}
|
||||
+169
-54
@@ -36,7 +36,7 @@ import (
|
||||
)
|
||||
|
||||
const tuiToolOutputLimit = 240
|
||||
const defaultResponseStyle = "balanced"
|
||||
const defaultResponseStyle = "concise"
|
||||
const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
@@ -100,35 +100,43 @@ type model struct {
|
||||
titledSessions map[string]bool
|
||||
// retitle* drive the sequential /retitle backfill: queued session ids still
|
||||
// awaiting a title, whether a backfill is running, and its progress counters.
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
retitleQueue []string
|
||||
retitleActive bool
|
||||
retitleTotal int
|
||||
retitleDone int
|
||||
retitleOK int
|
||||
usageTracker *usage.Tracker
|
||||
sessionCompactor SessionCompactor
|
||||
prService *PrService
|
||||
prState PrState
|
||||
prWatcherStop func()
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||||
// Reset by /new.
|
||||
turnLatencySum time.Duration
|
||||
turnLatencyCount int
|
||||
turnTTFTSum time.Duration
|
||||
turnTTFTCount int
|
||||
transcript []transcriptRow
|
||||
transcriptDetailed bool
|
||||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||||
@@ -318,9 +326,14 @@ type model struct {
|
||||
// mouse cursor with no button pressed, so it renders in a distinct style —
|
||||
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
||||
// (see wantsMouseCapture) since idle cursor movement carries no button.
|
||||
hover hoverTarget
|
||||
copyStatus string
|
||||
copyStatusSeq int
|
||||
hover hoverTarget
|
||||
copyStatus string
|
||||
copyStatusSeq int
|
||||
// fastToast is a transient /fast notice shown in the row above the composer for
|
||||
// fastToastDuration, then auto-cleared (seq-gated). It keeps ephemeral fast-lane
|
||||
// feedback out of the persistent transcript.
|
||||
fastToast string
|
||||
fastToastSeq int
|
||||
exitConfirmActive bool
|
||||
exitConfirmSeq int
|
||||
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
|
||||
@@ -451,6 +464,9 @@ type agentResponseMsg struct {
|
||||
// Turn metadata for settled rows that do not otherwise carry it.
|
||||
turnTools int
|
||||
turnElapsed time.Duration
|
||||
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
|
||||
// tool-only or errored turn). Set only on the success path.
|
||||
ttft time.Duration
|
||||
}
|
||||
|
||||
type agentRowMsg struct {
|
||||
@@ -726,6 +742,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
keyBindings: resolveKeyBindings(options.KeyBindings),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
@@ -1003,6 +1020,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.copyStatus = ""
|
||||
}
|
||||
return m, nil
|
||||
case fastToastExpiredMsg:
|
||||
if msg.seq == m.fastToastSeq {
|
||||
m.fastToast = ""
|
||||
}
|
||||
return m, nil
|
||||
case exitConfirmExpiredMsg:
|
||||
if msg.seq == m.exitConfirmSeq {
|
||||
m.exitConfirmActive = false
|
||||
@@ -1078,7 +1100,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case keyCtrl(msg, 'c'):
|
||||
return m.handleCtrlC()
|
||||
case keyCtrl(msg, 'o'):
|
||||
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return 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
|
||||
@@ -1088,12 +1110,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.setFileViewMode(fileViewFull), nil
|
||||
}
|
||||
return m.setFileViewMode(fileViewDiff), nil
|
||||
case keyCtrl(msg, 'e'):
|
||||
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }):
|
||||
// Release/recapture the mouse so the user can drag-select and copy text
|
||||
// natively (mouse capture otherwise intercepts terminal selection).
|
||||
m.mouseReleased = !m.mouseReleased
|
||||
if m.mouseReleased {
|
||||
return m.appendSystemNotice("Mouse released — drag to select and copy text. Press Ctrl+E again to re-enable mouse interaction (clicks, right-click paste)."), nil
|
||||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||||
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
|
||||
}
|
||||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
@@ -1268,7 +1291,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.permissionMode = nextPermissionMode(m.permissionMode)
|
||||
return m, nil
|
||||
}
|
||||
case keyCtrl(msg, 't'):
|
||||
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
@@ -1281,20 +1304,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.noBlockingModal() {
|
||||
return m.cycleReasoningEffort()
|
||||
}
|
||||
case keyCtrl(msg, 'p'):
|
||||
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
|
||||
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
|
||||
if m.noBlockingModal() && !m.plan.isEmpty() {
|
||||
m.plan.expanded = !m.plan.expanded
|
||||
return m, nil
|
||||
}
|
||||
case keyCtrl(msg, 'b'):
|
||||
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }):
|
||||
// Ctrl+B collapses / restores the right context sidebar. Only acts when
|
||||
// the sidebar would otherwise be on screen (managed mode, wide enough,
|
||||
// real conversation) so it's a no-op — not a confusing notice — on the
|
||||
// home screen or a narrow terminal. Hiding reflows the chat to full
|
||||
// width, so mirror the width-change bookkeeping (re-wrap the streaming
|
||||
// fade, resize the composer) the WindowSizeMsg path does.
|
||||
if m.noBlockingModal() && m.sidebarAvailable() {
|
||||
if m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
// Just show/hide — no transcript notice. The reflow IS the feedback,
|
||||
// and emitting a line every toggle piled up noise in the chat.
|
||||
m.sidebarHidden = !m.sidebarHidden
|
||||
@@ -1669,17 +1692,38 @@ 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)
|
||||
if msg.request.Action == agent.PermissionActionPrompt {
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: msg.request,
|
||||
decide: msg.decide,
|
||||
}
|
||||
return m, nil
|
||||
case askUserRequestMsg:
|
||||
@@ -1835,6 +1879,16 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.streamingText = ""
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
// Roll the completed run's wall-time into the session's rolling average so
|
||||
// /context can surface typical turn latency, not just token counts.
|
||||
if msg.turnElapsed > 0 {
|
||||
m.turnLatencySum += msg.turnElapsed
|
||||
m.turnLatencyCount++
|
||||
}
|
||||
if msg.ttft > 0 {
|
||||
m.turnTTFTSum += msg.ttft
|
||||
m.turnTTFTCount++
|
||||
}
|
||||
if msg.specReview != nil {
|
||||
m = m.activateSpecReview(*msg.specReview)
|
||||
}
|
||||
@@ -2097,12 +2151,13 @@ func (m model) View() tea.View {
|
||||
var content string
|
||||
if m.setup.visible {
|
||||
content = m.setupView(chatWidth(m.width))
|
||||
} else if m.helpOverlay {
|
||||
content = m.renderKeybindingHelpOverlay(chatWidth(m.width), m.height)
|
||||
} else if m.transcriptDetailed {
|
||||
content = m.detailedTranscriptView()
|
||||
} else {
|
||||
} 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 {
|
||||
content = m.detailedTranscriptView()
|
||||
}
|
||||
|
||||
view := tea.NewView(content)
|
||||
@@ -2173,6 +2228,11 @@ 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)
|
||||
@@ -2180,6 +2240,8 @@ func (m model) transcriptView() string {
|
||||
pickerOverlay := m.pickerOverlay(width)
|
||||
viewportOverlay := ""
|
||||
switch {
|
||||
case helpOverlayContent != "":
|
||||
viewportOverlay = helpOverlayContent
|
||||
case providerOverlay != "":
|
||||
viewportOverlay = providerOverlay
|
||||
case mcpAddOverlay != "":
|
||||
@@ -2295,7 +2357,9 @@ func (m model) footerView(width int) string {
|
||||
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
|
||||
// cue on the right when scrolled up. Always one line (blank when nothing shows),
|
||||
// so the footer height is unchanged.
|
||||
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
|
||||
if toast := strings.TrimSpace(m.fastToast); toast != "" {
|
||||
footer.WriteString(rightAlignedLine(renderFastToast(toast), width))
|
||||
} else if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
|
||||
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
|
||||
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
|
||||
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
|
||||
@@ -2331,6 +2395,10 @@ func (m model) composerIdleHint() string {
|
||||
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
|
||||
return ""
|
||||
}
|
||||
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
|
||||
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
|
||||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||||
|
||||
var hint string
|
||||
switch widthTier(m.width) {
|
||||
case tierTiny:
|
||||
@@ -2338,9 +2406,9 @@ func (m model) composerIdleHint() string {
|
||||
case tierNarrow:
|
||||
hint = "? shortcuts"
|
||||
case tierMedium:
|
||||
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+E copy"
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey)
|
||||
default:
|
||||
hint = "? shortcuts · Ctrl+B sidebar · Ctrl+O detail · Ctrl+E copy · Shift+Tab mode"
|
||||
hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
|
||||
}
|
||||
return zeroTheme.faint.Render(hint)
|
||||
}
|
||||
@@ -3586,10 +3654,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case commandClear:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
|
||||
// Clearing wipes the visible transcript only — the session's context is
|
||||
// intact, so the next prompt still replays the full history. Say so, and
|
||||
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
|
||||
// Scrollback above can't be un-printed; a faint divider marks where the
|
||||
// cleared surface ended and the frontier restarts for the fresh transcript.
|
||||
m.resetFlushFrontier("· cleared ·")
|
||||
return m, nil
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
// user cancel first. Idle, /new saves the current session (already on disk)
|
||||
// and clears the conversation in place.
|
||||
if m.pending || m.compactInFlight {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
case commandExit:
|
||||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||||
// defer the quit until its checkpoint session events flush — quitting
|
||||
@@ -3745,6 +3826,12 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleEffortCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandFast:
|
||||
var text string
|
||||
m, text = m.handleFastCommand(command.text)
|
||||
// Fast-lane feedback is ephemeral: show it as a transient toast above the
|
||||
// composer (auto-dismissed) instead of a permanent transcript line.
|
||||
return m.showFastToast(text)
|
||||
case commandStyle:
|
||||
text := ""
|
||||
m, text = m.handleStyleCommand(command.text)
|
||||
@@ -4074,6 +4161,9 @@ func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
|
||||
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
started := m.now()
|
||||
// firstTokenAt is stamped when the first token (reasoning or text) streams,
|
||||
// so the turn can report time-to-first-token alongside total wall time.
|
||||
var firstTokenAt time.Time
|
||||
toolCalls := 0
|
||||
rows := []transcriptRow{}
|
||||
usageEvents := []zeroruntime.Usage{}
|
||||
@@ -4166,6 +4256,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
|
||||
onText := options.OnText
|
||||
options.OnText = func(delta string) {
|
||||
if firstTokenAt.IsZero() {
|
||||
firstTokenAt = m.now()
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) != "" {
|
||||
flushReasoning(m.now())
|
||||
}
|
||||
@@ -4261,6 +4354,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
onReasoning := options.OnReasoning
|
||||
options.OnReasoning = func(delta string) {
|
||||
now := m.now()
|
||||
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
|
||||
firstTokenAt = now
|
||||
}
|
||||
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
|
||||
reasoningStarted = now
|
||||
}
|
||||
@@ -4497,6 +4593,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
}
|
||||
flushReasoning(m.now())
|
||||
elapsed := m.now().Sub(started)
|
||||
ttft := time.Duration(0)
|
||||
if !firstTokenAt.IsZero() {
|
||||
ttft = firstTokenAt.Sub(started)
|
||||
}
|
||||
rows = append(rows, transcriptRow{
|
||||
kind: rowAssistant,
|
||||
text: result.FinalAnswer,
|
||||
@@ -4514,7 +4614,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
"content": result.FinalAnswer,
|
||||
},
|
||||
})
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed}
|
||||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4525,6 +4625,21 @@ 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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package tui
|
||||
|
||||
import "testing"
|
||||
|
||||
// The model picker must title each row with the model NAME, not the models.dev
|
||||
// marketing description. Regression: Ollama Cloud models resolved full-sentence
|
||||
// descriptions from the remote catalog, so rows showed sentences and several
|
||||
// models sharing one description looked like exact duplicates (issue: /model
|
||||
// list rendered descriptions as titles).
|
||||
func TestModelPickerDisplayNamePrefersModelName(t *testing.T) {
|
||||
sentence := "DeepSeek chat model for instruction following, coding, and analysis"
|
||||
cases := []struct {
|
||||
id string
|
||||
description string
|
||||
want string
|
||||
}{
|
||||
{"deepseek-v3.2", sentence, "Deepseek V3.2"},
|
||||
{"deepseek-v3.2-thinking", sentence, "Deepseek V3.2 Thinking"},
|
||||
{"glm-5.2", "GLM flagship model", "GLM 5.2"},
|
||||
{"qwen3-coder:480b", "Qwen coding agent model for repository tasks", "Qwen3 Coder 480b"},
|
||||
{"anthropic/claude-sonnet-4.6", "some blurb", "Claude Sonnet 4.6"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := modelPickerDisplayName(tc.id, tc.description); got != tc.want {
|
||||
t.Errorf("modelPickerDisplayName(%q, %q) = %q, want %q", tc.id, tc.description, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two distinct model ids that share the same catalog description must render as
|
||||
// distinct rows (the "duplicate-looking rows" symptom), never as the shared
|
||||
// sentence.
|
||||
func TestModelPickerDisplayNameDistinctForSharedDescription(t *testing.T) {
|
||||
shared := "Mistral coding agent model for repository tasks and software engineering"
|
||||
a := modelPickerDisplayName("codestral-2", shared)
|
||||
b := modelPickerDisplayName("devstral-2", shared)
|
||||
if a == b {
|
||||
t.Fatalf("shared description collapsed rows to the same title: %q", a)
|
||||
}
|
||||
if a == shared || b == shared {
|
||||
t.Fatalf("row title used the description sentence: a=%q b=%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// With no id to name the row, a non-generic description is still an acceptable
|
||||
// fallback; a generic placeholder (or nothing) falls back to "model".
|
||||
func TestModelPickerDisplayNameFallsBackToDescriptionOnlyWithoutID(t *testing.T) {
|
||||
if got := modelPickerDisplayName("", "Friendly Name"); got != "Friendly Name" {
|
||||
t.Fatalf("empty id should fall back to the description, got %q", got)
|
||||
}
|
||||
if got := modelPickerDisplayName("", "catalog default"); got != "model" {
|
||||
t.Fatalf("generic description with no id should yield %q, got %q", "model", got)
|
||||
}
|
||||
if got := modelPickerDisplayName("", ""); got != "model" {
|
||||
t.Fatalf("empty id and description should yield %q, got %q", "model", got)
|
||||
}
|
||||
}
|
||||
@@ -340,8 +340,11 @@ func TestClearCommandResetsTranscript(t *testing.T) {
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript, got %#v", next.transcript)
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected clear to reset transcript to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "/new") {
|
||||
t.Fatalf("expected clear to point users to /new, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestStartNewSessionResetsState(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.sessionEvents = []sessions.Event{{Type: sessions.EventMessage}}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
// Stage attachments + a queued message that /new must not leak into the new session.
|
||||
m.pendingImages = make([]zeroruntime.ImageBlock, 1)
|
||||
m.pendingImageLabels = []string{"pic.png"}
|
||||
m.pendingDocuments = []pendingDocument{{label: "doc.pdf"}}
|
||||
m.queuedMessage = "queued"
|
||||
|
||||
next := m.startNewSession()
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected active session id cleared, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
if len(next.sessionEvents) != 0 {
|
||||
t.Fatalf("expected session events cleared, got %d", len(next.sessionEvents))
|
||||
}
|
||||
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected transcript reset to welcome + note, got %#v", next.transcript)
|
||||
}
|
||||
// The note must name the prior session id so the user can /resume it.
|
||||
if !transcriptContains(next.transcript, "sess-old") {
|
||||
t.Fatalf("expected note to reference previous session id, got %#v", next.transcript)
|
||||
}
|
||||
// Staged attachments and the queued message must not leak into the new session.
|
||||
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 || len(next.pendingDocuments) != 0 || next.queuedMessage != "" {
|
||||
t.Fatalf("startNewSession must clear staged input, got images=%d labels=%d docs=%d queued=%q",
|
||||
len(next.pendingImages), len(next.pendingImageLabels), len(next.pendingDocuments), next.queuedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandStartsFreshSession(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.activeSession.SessionID != "" {
|
||||
t.Fatalf("expected /new to clear the active session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandDoesNotResetDuringRun(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.activeSession = sessions.Metadata{SessionID: "sess-old"}
|
||||
m.pending = true
|
||||
m.input.SetValue("/new")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
// The safety invariant: /new must never strand an in-flight session.
|
||||
if next.activeSession.SessionID != "sess-old" {
|
||||
t.Fatalf("/new must not reset an in-flight session, got %q", next.activeSession.SessionID)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ type Options struct {
|
||||
// Notify configures completion / awaiting-input notifications.
|
||||
Notify config.NotifyConfig
|
||||
|
||||
// KeyBindings configures remappable TUI keybindings. An empty/zero
|
||||
// KeyBindingsConfig means "use built-in defaults" for each action.
|
||||
KeyBindings config.KeyBindingsConfig
|
||||
|
||||
// AltScreen tells the model it is running inside Bubble Tea's alternate
|
||||
// screen. Run sets this for the interactive app; tests can leave it false
|
||||
// to exercise the native scrollback renderer.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func permissionModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.pending = true
|
||||
m.activeRunID = 7
|
||||
m.width = 96
|
||||
return m
|
||||
}
|
||||
|
||||
// The deadlock: a permission request whose Action is NOT prompt (e.g. a
|
||||
// sandbox-allowed command that still blocked to request additional permissions)
|
||||
// must be resolved immediately — the agent goroutine is parked awaiting a
|
||||
// decision. Before the fix the handler dropped it, and the run hung forever
|
||||
// (the reported "stuck for 33 minutes"). Fail closed: a non-prompt allow denies.
|
||||
func TestPermissionRequestNonPromptResolvesFailClosed(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionAllow},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("DEADLOCK: decide was never called for a non-prompt request — the agent goroutine would park forever")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("decision = %q, want deny (fail closed — never silently grant an unsurfaced permission)", got.Action)
|
||||
}
|
||||
if next.pendingPermission != nil {
|
||||
t.Fatal("a non-prompt request must not open a pending prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// A superseded/stale run's request (runID mismatch) must also be resolved
|
||||
// (cancelled) so its parked goroutine unblocks instead of depending on
|
||||
// context-cancel ordering.
|
||||
func TestPermissionRequestStaleRunResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t) // activeRunID = 7
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 3, // != activeRunID
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt},
|
||||
decide: decide,
|
||||
})
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("stale-run request was dropped without resolving — its agent goroutine would park")
|
||||
}
|
||||
if got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("decision = %q, want cancel for a superseded run", got.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit-cancel action is honored as a cancel (not denied).
|
||||
func TestPermissionRequestCancelActionResolvesCancel(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
var got *agent.PermissionDecision
|
||||
decide := func(d agent.PermissionDecision) { got = &d }
|
||||
|
||||
m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionCancel},
|
||||
decide: decide,
|
||||
})
|
||||
if got == nil || got.Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("cancel action must resolve as cancel, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A genuine prompt request stores the pending prompt and does NOT resolve yet —
|
||||
// the decision comes from the user's reply. (Regression guard: the fix must not
|
||||
// accidentally auto-resolve real prompts.)
|
||||
func TestPermissionRequestPromptDefersToUser(t *testing.T) {
|
||||
m := permissionModel(t)
|
||||
resolved := false
|
||||
decide := func(agent.PermissionDecision) { resolved = true }
|
||||
|
||||
updated, _ := m.Update(permissionRequestMsg{
|
||||
runID: 7,
|
||||
request: agent.PermissionRequest{
|
||||
ToolCallID: "c1", ToolName: "bash", Action: agent.PermissionActionPrompt,
|
||||
Reason: "runs a shell command", AvailableDecisions: []agent.PermissionDecisionAction{agent.PermissionDecisionAllow},
|
||||
},
|
||||
decide: decide,
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
if resolved {
|
||||
t.Fatal("a real prompt must not be auto-resolved; it awaits the user")
|
||||
}
|
||||
if next.pendingPermission == nil {
|
||||
t.Fatal("a prompt request must open a pending permission prompt")
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -43,6 +43,10 @@ type pickerItem struct {
|
||||
Remote bool
|
||||
Local bool
|
||||
Favorite bool
|
||||
// FastVariant is the display name of this model's faster same-family variant
|
||||
// (empty when it has none). Set only for /model rows; drives the "f" fast-mode
|
||||
// badge and the focused-row "⚡ fast: … · /fast on" hint.
|
||||
FastVariant string
|
||||
}
|
||||
|
||||
// commandPicker is a generic single-select overlay reused by /model and /effort
|
||||
@@ -155,9 +159,35 @@ func (m model) newModelPicker() *commandPicker {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
decorateFastVariantItems(registry, items)
|
||||
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
||||
}
|
||||
|
||||
// decorateFastVariantItems stamps each /model row with the display name of its
|
||||
// fast variant (if any), in one pass over the assembled list rather than in every
|
||||
// item builder. Rows whose model id is not in the curated registry (custom or
|
||||
// live-discovered) simply keep an empty FastVariant and show no badge.
|
||||
func decorateFastVariantItems(registry modelregistry.Registry, items []pickerItem) {
|
||||
for index := range items {
|
||||
id := strings.TrimSpace(items[index].Value)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if fast, ok := registry.FastVariant(id); ok {
|
||||
items[index].FastVariant = fastVariantDisplayName(fast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fastVariantDisplayName is the user-facing name of a fast variant, preferring
|
||||
// its display name and falling back to the id.
|
||||
func fastVariantDisplayName(entry modelregistry.ModelEntry) string {
|
||||
if name := strings.TrimSpace(entry.DisplayName); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(entry.ID)
|
||||
}
|
||||
|
||||
// modelPickerProviders returns the providers to list in /model: all saved
|
||||
// providers, falling back to the active profile when none were threaded in.
|
||||
func (m model) modelPickerProviders() []config.ProviderProfile {
|
||||
@@ -452,11 +482,17 @@ func registryModelPickerMeta(entry modelregistry.ModelEntry) string {
|
||||
}
|
||||
|
||||
func modelPickerDisplayName(id string, description string) string {
|
||||
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
// The row title is the model NAME, not its marketing blurb. Catalog/discovery
|
||||
// descriptions (from models.dev) are full sentences, and several models share
|
||||
// the same one — using them as titles showed sentence-long rows that looked like
|
||||
// exact duplicates and hid the actual id (which only survived in the meta line).
|
||||
// Prefer the (prettified) id; fall back to a non-generic description only when
|
||||
// there is no id to name the row.
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
if description = strings.TrimSpace(description); description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
return "model"
|
||||
}
|
||||
name := id
|
||||
@@ -613,7 +649,15 @@ func (m model) normalizeProfileForProvider(provider providercatalog.Descriptor)
|
||||
strings.TrimSpace(profile.Name) == "" ||
|
||||
strings.TrimSpace(profile.CatalogID) == ""
|
||||
if normalizeIdentity {
|
||||
profile.Name = provider.ID
|
||||
// Only canonicalize an empty or generic placeholder name — never clobber a
|
||||
// real user-provided profile name. The credential store is keyed by
|
||||
// profile.Name (SecureProviderProfile stores under Name), so rewriting it
|
||||
// here made the later profileWithCredential lookup miss the saved key and
|
||||
// rebuild a keyless provider — a 401 on every /model switch for any profile
|
||||
// named differently from its catalog id (e.g. "my-openai").
|
||||
if strings.TrimSpace(profile.Name) == "" || genericProviderCatalogID(profile.Name) {
|
||||
profile.Name = provider.ID
|
||||
}
|
||||
profile.CatalogID = provider.ID
|
||||
}
|
||||
if strings.TrimSpace(profile.BaseURL) == "" {
|
||||
|
||||
@@ -976,3 +976,49 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
t.Fatal("switching to ollama should warm both the generic and ollama-specific discovery commands")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProfileForProviderPreservesCustomName(t *testing.T) {
|
||||
openai, ok := providercatalog.Get("openai")
|
||||
if !ok {
|
||||
t.Fatal("openai descriptor missing from catalog")
|
||||
}
|
||||
|
||||
// A first-party provider set up with a custom profile name and the baseURL at
|
||||
// the catalog default. normalizeIdentity is true here (baseURL matches), but
|
||||
// the real name must survive: it is the credential-store key, so clobbering it
|
||||
// dropped the saved key and 401'd every /model switch (issue #440).
|
||||
m := model{providerProfile: config.ProviderProfile{
|
||||
Name: "my-openai",
|
||||
CatalogID: "openai",
|
||||
BaseURL: openai.DefaultBaseURL,
|
||||
APIKeyStored: true,
|
||||
}}
|
||||
got := m.normalizeProfileForProvider(openai)
|
||||
if got.Name != "my-openai" {
|
||||
t.Fatalf("Name = %q, want it preserved as %q (credential store is keyed by Name)", got.Name, "my-openai")
|
||||
}
|
||||
if got.CatalogID != "openai" {
|
||||
t.Fatalf("CatalogID = %q, want it canonicalized to %q", got.CatalogID, "openai")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
|
||||
openai, ok := providercatalog.Get("openai")
|
||||
if !ok {
|
||||
t.Fatal("openai descriptor missing from catalog")
|
||||
}
|
||||
|
||||
// Empty and generic placeholder names still get canonicalized to the catalog
|
||||
// id — those carry no meaningful credential-store key to preserve.
|
||||
for _, name := range []string{"", "custom-openai-compatible"} {
|
||||
m := model{providerProfile: config.ProviderProfile{
|
||||
Name: name,
|
||||
BaseURL: openai.DefaultBaseURL,
|
||||
CatalogID: name,
|
||||
}}
|
||||
got := m.normalizeProfileForProvider(openai)
|
||||
if got.Name != "openai" {
|
||||
t.Fatalf("Name = %q for placeholder %q, want canonicalized to %q", got.Name, name, "openai")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ type providerWizardState struct {
|
||||
selectedProvider int
|
||||
models []providerWizardModel
|
||||
selectedModel int
|
||||
providerSearch string
|
||||
modelSearch string
|
||||
baseURL string
|
||||
profileName string
|
||||
@@ -343,7 +344,18 @@ func providerWizardProviders() []providercatalog.Descriptor {
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) currentProvider() providercatalog.Descriptor {
|
||||
if wizard == nil || len(wizard.providers) == 0 {
|
||||
if wizard == nil {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
if wizard.step == providerWizardStepProvider {
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(providers)-1)
|
||||
return providers[wizard.selectedProvider]
|
||||
}
|
||||
if len(wizard.providers) == 0 {
|
||||
return providercatalog.Descriptor{}
|
||||
}
|
||||
wizard.selectedProvider = clampInt(wizard.selectedProvider, 0, len(wizard.providers)-1)
|
||||
@@ -399,10 +411,11 @@ func (wizard *providerWizardState) move(delta int) {
|
||||
}
|
||||
wizard.selectedMethod = ((wizard.selectedMethod+delta)%len(options) + len(options)) % len(options)
|
||||
case providerWizardStepProvider:
|
||||
if len(wizard.providers) == 0 {
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
return
|
||||
}
|
||||
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(wizard.providers) + len(wizard.providers)) % len(wizard.providers)
|
||||
wizard.selectedProvider = ((wizard.selectedProvider+delta)%len(providers) + len(providers)) % len(providers)
|
||||
wizard.selectedModel = 0
|
||||
wizard.modelSearch = ""
|
||||
wizard.baseURL = ""
|
||||
@@ -450,6 +463,21 @@ func (wizard *providerWizardState) advance() {
|
||||
if wizard.oauthMode {
|
||||
return
|
||||
}
|
||||
if len(wizard.filteredProviders()) == 0 {
|
||||
return
|
||||
}
|
||||
// Resolve the selected provider's index in the full list before clearing
|
||||
// the search — selectedProvider is an index into the filtered slice, and
|
||||
// clearing the search swaps to the full list.
|
||||
if selected := wizard.currentProvider(); selected.ID != "" {
|
||||
for i, p := range wizard.providers {
|
||||
if p.ID == selected.ID {
|
||||
wizard.selectedProvider = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
wizard.providerSearch = ""
|
||||
wizard.refreshModels()
|
||||
wizard.err = ""
|
||||
if providerWizardNeedsEndpoint(wizard.currentProvider()) {
|
||||
@@ -513,6 +541,7 @@ func (wizard *providerWizardState) retreat() {
|
||||
wizard.step = providerWizardStepMethod
|
||||
case providerWizardStepEndpoint:
|
||||
wizard.step = providerWizardStepProvider
|
||||
wizard.providerSearch = ""
|
||||
case providerWizardStepName:
|
||||
wizard.step = providerWizardStepEndpoint
|
||||
case providerWizardStepCredential:
|
||||
@@ -628,6 +657,20 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
m.providerWizard.currentProvider().OAuthDeviceFlow {
|
||||
return m.startProviderDeviceLogin()
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepProvider {
|
||||
switch {
|
||||
case keyText(msg) != "":
|
||||
m.providerWizard.appendProviderSearch(keyRunes(msg))
|
||||
return m, nil
|
||||
case keyBackspace(msg):
|
||||
m.providerWizard.deleteProviderSearchRune()
|
||||
return m, nil
|
||||
case keyCtrl(msg, 'u'):
|
||||
m.providerWizard.providerSearch = ""
|
||||
m.providerWizard.selectedProvider = 0
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.providerWizard.step == providerWizardStepEndpoint {
|
||||
switch {
|
||||
case keyText(msg) != "":
|
||||
@@ -746,6 +789,8 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
switch m.providerWizard.step {
|
||||
case providerWizardStepProvider:
|
||||
m.providerWizard.appendProviderSearch([]rune(content))
|
||||
case providerWizardStepEndpoint:
|
||||
m.providerWizard.appendBaseURL([]rune(content))
|
||||
case providerWizardStepName:
|
||||
@@ -879,6 +924,50 @@ func (wizard *providerWizardState) deleteModelSearchRune() {
|
||||
wizard.selectedModel = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) appendProviderSearch(runes []rune) {
|
||||
for _, r := range runes {
|
||||
if unicode.IsControl(r) {
|
||||
continue
|
||||
}
|
||||
wizard.providerSearch += string(r)
|
||||
}
|
||||
wizard.selectedProvider = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) deleteProviderSearchRune() {
|
||||
if wizard.providerSearch == "" {
|
||||
return
|
||||
}
|
||||
runes := []rune(wizard.providerSearch)
|
||||
wizard.providerSearch = string(runes[:len(runes)-1])
|
||||
wizard.selectedProvider = 0
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) filteredProviders() []providercatalog.Descriptor {
|
||||
if wizard == nil {
|
||||
return nil
|
||||
}
|
||||
query := strings.ToLower(strings.TrimSpace(wizard.providerSearch))
|
||||
if query == "" {
|
||||
return append([]providercatalog.Descriptor{}, wizard.providers...)
|
||||
}
|
||||
providers := make([]providercatalog.Descriptor, 0, len(wizard.providers))
|
||||
for _, provider := range wizard.providers {
|
||||
if providerMatchesQuery(provider, query) {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func providerMatchesQuery(provider providercatalog.Descriptor, query string) bool {
|
||||
if query == "" {
|
||||
return true
|
||||
}
|
||||
haystack := strings.ToLower(strings.Join([]string{provider.ID, provider.Name, strings.Join(provider.Aliases, " ")}, " "))
|
||||
return strings.Contains(haystack, query)
|
||||
}
|
||||
|
||||
func (m model) applyProviderWizard() (model, tea.Cmd) {
|
||||
wizard := m.providerWizard
|
||||
if wizard == nil {
|
||||
@@ -1248,9 +1337,15 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
|
||||
header = "Choose an OAuth provider"
|
||||
}
|
||||
lines := []string{zeroTheme.accent.Render(header)}
|
||||
maxVisible := minInt(maxProviderWizardProvidersVisible, len(wizard.providers))
|
||||
start := selectableListStart(len(wizard.providers), maxVisible, wizard.selectedProvider)
|
||||
for offset, provider := range wizard.providers[start : start+maxVisible] {
|
||||
lines = append(lines, wizard.renderProviderSearch(width))
|
||||
providers := wizard.filteredProviders()
|
||||
if len(providers) == 0 {
|
||||
lines = append(lines, zeroTheme.faint.Render(" no matching providers"))
|
||||
return lines
|
||||
}
|
||||
maxVisible := minInt(maxProviderWizardProvidersVisible, len(providers))
|
||||
start := selectableListStart(len(providers), maxVisible, wizard.selectedProvider)
|
||||
for offset, provider := range providers[start : start+maxVisible] {
|
||||
lines = append(lines, wizard.renderSelectableProvider(width, start+offset, provider))
|
||||
}
|
||||
// A failed OAuth attempt leaves the wizard on this list (it does not advance),
|
||||
@@ -1265,6 +1360,11 @@ func (wizard *providerWizardState) renderProviderStep(width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
func (wizard *providerWizardState) renderProviderSearch(width int) string {
|
||||
query := strings.TrimSpace(wizard.providerSearch)
|
||||
return providerWizardInputLine("search > ", query, "provider name, id, or alias...", width)
|
||||
}
|
||||
|
||||
// providerWizardOAuthErrHint returns a provider-specific next step for a failed
|
||||
// OAuth login. Hugging Face ships no public client_id (unlike xAI), so a login
|
||||
// can only work once the operator registers an app and supplies its client_id.
|
||||
@@ -1467,6 +1567,9 @@ func (wizard *providerWizardState) renderSelectableModel(width int, index int, m
|
||||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||||
}
|
||||
left := marker + surface(zeroTheme.ink).Render(model.displayLabel())
|
||||
if _, ok := fastVariantEntryFor(model.ID); ok {
|
||||
left += renderFastBadge(surface(zeroTheme.accent))
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
|
||||
@@ -1478,6 +1581,9 @@ func providerWizardModelDetail(model providerWizardModel) string {
|
||||
if meta := strings.TrimSpace(model.Meta); meta != "" {
|
||||
parts = append(parts, meta)
|
||||
}
|
||||
if fast, ok := fastVariantEntryFor(model.ID); ok {
|
||||
parts = append(parts, fastVariantHint(fastVariantDisplayName(fast)))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
@@ -1602,7 +1708,7 @@ func providerWizardEndpointError(value string) string {
|
||||
return "endpoint URL must start with http:// or https://"
|
||||
}
|
||||
if parsed.Scheme == "http" && !providerWizardIsLoopbackHost(parsed.Hostname()) {
|
||||
return "endpoint URL must use https:// unless it is local loopback"
|
||||
return "endpoint URL must use https:// unless it is local loopback or a private network address"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1613,7 +1719,7 @@ func providerWizardIsLoopbackHost(host string) bool {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
return ip != nil && (ip.IsLoopback() || ip.IsPrivate())
|
||||
}
|
||||
|
||||
func providerWizardDisplayName(provider providercatalog.Descriptor, baseURL string, profileName string) string {
|
||||
|
||||
@@ -44,11 +44,19 @@ func (m model) advanceProviderWizard() (model, tea.Cmd) {
|
||||
// keep/replace/remove before re-entering credentials.
|
||||
if m.providerWizard.step == providerWizardStepProvider && !m.providerWizard.oauthMode {
|
||||
if name, ok := m.wizardProviderStoredKey(m.providerWizard.currentProvider()); ok {
|
||||
m.providerWizard.manageProviderName = name
|
||||
m.providerWizard.manageKeyCursor = 0
|
||||
m.providerWizard.err = ""
|
||||
m.providerWizard.step = providerWizardStepManageKey
|
||||
return m, nil
|
||||
// Generic/custom providers (custom-openai-compatible etc.) all share
|
||||
// the same CatalogID — matching on CatalogID would block creating a
|
||||
// second instance. Skip ManageKey and fall through to the shared
|
||||
// advance() path below; the user can overwrite by re-entering the
|
||||
// same name or create a new one with a different name.
|
||||
if !providerWizardNeedsEndpoint(m.providerWizard.currentProvider()) {
|
||||
m.providerWizard.manageProviderName = name
|
||||
m.providerWizard.manageKeyCursor = 0
|
||||
m.providerWizard.err = ""
|
||||
m.providerWizard.step = providerWizardStepManageKey
|
||||
return m, nil
|
||||
}
|
||||
m.providerWizard.manageProviderName = ""
|
||||
}
|
||||
}
|
||||
previous := m.providerWizard.step
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user