Compare commits

..
Author SHA1 Message Date
Gnanam 1519ada0f2 notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.

Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
  - reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
  - no-op when the URL is unset/blank, so it is safe to call unconditionally
  - sourcing the URL from the environment keeps the secret token out of any
    on-disk config file

Attached at both notifier construction sites:
  - headless exec: failed deliveries log to stderr (never stdout); the sink
    redacts before logging
  - TUI: logf=nil so a delivery failure can't corrupt the alt-screen

The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.

deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
2026-06-15 19:15:47 +05:30
150 changed files with 1696 additions and 13250 deletions

No files matched your search

Binary file not shown.
-184
View File
@@ -1,184 +0,0 @@
# OAuth logins & using ChatGPT / Claude subscriptions
Zero supports two distinct things people mean by "log in with OAuth":
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
fully built in (`zero auth login …`). Zero attaches the token to model calls
automatically.
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
pay-per-token API key — only possible through a **local proxy**, for the
reasons documented below. Zero ships a convenience preset and this recipe.
---
## 1. OAuth login for a provider or gateway (built in)
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
env vars and log in:
```sh
export ZERO_OAUTH_ACME_CLIENT_ID=…
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
export ZERO_OAUTH_ACME_SCOPES="openid profile"
zero auth login acme # browser (loopback); --device for headless
zero auth status
```
When a login exists for a provider, the **OpenAI and Anthropic** providers send
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
`401`) instead of the API key. With no login they use the API key exactly as
before. Tokens are stored 0600 (or the OS keyring with
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
### In the setup wizard (`/provider`)
Running `/provider` opens a **"How do you want to connect?"** chooser:
```text
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
```
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
```text
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
```
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
**d** on the list to get a code to enter on another device instead of opening a
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
equivalent is `zero auth login xai --device`.
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
### Built-in OAuth providers
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
the provider profile and used normally.
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
ships a built-in preset for the public Grok-CLI client, but to keep third-party
client identities out of the default credential path it is **off by default**.
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
(browser, or `--device` for headless) works one-click; the token is used directly
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
`zero auth login <name>`.
---
## 2. ChatGPT / Claude subscriptions — why a proxy is required
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
**not** work as a drop-in bearer against the standard APIs:
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
requires mimicking the official Codex client (originator + account-id header).
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
for third-party use unless the request spoofs the Claude Code identity
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
then on **April 4 2026** enforcement landed and subscription OAuth tokens
**stopped working in third-party harnesses** (starting with OpenClaw, then the
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
both per-token, not the flat subscription. The request to allow subscription use
(claude-code #37205) was closed *"not planned."*
So Zero does **not** call those backends directly or spoof those clients — that
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
The robust, supported pattern is a **local proxy** that holds your subscription
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
just points at it.
### ChatGPT via a local proxy
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
preset (no API key — the proxy authenticates):
```jsonc
// ~/.config/zero/config.json (or ./.zero/config.json)
{
"activeProvider": "chatgpt",
"providers": [
{
"name": "chatgpt",
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
"model": "gpt-5" // whatever model your proxy serves
}
]
}
```
```sh
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
```
### Claude via a local proxy
There is no single canonical Claude OAuth-proxy port, so use the generic
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
endpoint:
```jsonc
{
"activeProvider": "claude",
"providers": [
{
"name": "claude",
"catalogID": "custom-anthropic-compatible",
"baseURL": "http://localhost:<port>", // your Claude proxy
"apiKey": "unused-by-proxy",
"model": "claude-sonnet-4.5"
}
]
}
```
---
## 3. Supported alternatives (no proxy)
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
Bills as API usage.
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
first-class subscription session.
---
## Notes
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
setup; they are not an endorsement of any specific proxy implementation.
- Subscription-via-proxy depends on third-party tools and undocumented vendor
backends; it can break without notice. The API-key path is the stable one.
+22 -21
View File
@@ -1,36 +1,37 @@
module github.com/Gitlawb/zero
go 1.25.0
go 1.24.2
toolchain go1.26.4
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
golang.org/x/sys v0.38.0
mvdan.cc/sh/v3 v3.11.0
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/go-quicktest/qt v1.102.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
+48 -40
View File
@@ -1,33 +1,31 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -36,23 +34,33 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
+2 -39
View File
@@ -90,48 +90,11 @@ func toolFailureStopAnswer(toolName string, count int) string {
"Please check the request or adjust the tool arguments."
}
// The no-output stop answer is assembled from these fixed parts (only the turn
// count varies). IsNoProgressStop matches all three so a legitimate message that
// merely quotes the marker substring is not misclassified as a failed empty run.
const (
noOutputStopPrefix = "Agent stopped after "
noOutputStopMarker = "with no output (no visible text and no tool calls)"
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// 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
}
// 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 + " " +
// suffix, where only the integer turn count varies — rather than just looking for
// the three parts in order. A loose check (prefix && contains-marker && suffix)
// would misclassify a genuine assistant/tool message that merely quotes the
// marker amid other prose, which would wrongly hide a real session from /resume
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
rest := trimmed[len(noOutputStopPrefix):]
const turnsSep = " turns "
sep := strings.Index(rest, turnsSep)
if sep < 0 {
return false
}
// The text between the prefix and " turns " must be exactly the bare integer
// count; anything else means this isn't the guard's own answer.
if _, err := strconv.Atoi(rest[:sep]); err != nil {
return false
}
// The marker must be immediately followed (one space) by the suffix and then
// end — no arbitrary text wedged in between.
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
return "Agent stopped after " + strconv.Itoa(turns) +
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
}
// Reminder markers are stable substrings used both to build the reminder text
-36
View File
@@ -1,36 +0,0 @@
package agent
import "testing"
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
// The actual guardrail answer (any turn count) is recognized.
for _, turns := range []int{1, 3, 12} {
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected := []string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
"Agent stopped after 3 turns.", // prefix only
"to avoid consuming tokens without making progress.", // suffix only
"Here is your e-commerce site.", // unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for _, content := range rejected {
if IsNoProgressStop(content) {
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
}
}
}
-11
View File
@@ -71,17 +71,6 @@ work.
managers). It is not a substitute for the file tools.
- Treat tool output as ground truth. If a command fails, read the error, form a
hypothesis, and address the root cause; do not retry the same call blindly.
- When a web search or fetch tool is available, search the web before answering
about an external entity, product, library, model, company, version, or recent
release you do not recognize. Do not guess, and do not assume the most common
meaning of an ambiguous name — use the conversation's domain (this is an AI /
software tool) to disambiguate. If the first results look off-topic, refine the
query and search again before answering; never reply that you don't know without
searching first.
- Do not web-search timeless facts, fundamentals, or anything answerable from the
workspace — read the code with the file tools instead. Keep queries short (a few
words), start broad then narrow, and scale the number of searches to the
question: one for a single fact, a few for a deeper answer.
## Permission and safety
-2
View File
@@ -19,8 +19,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"final response",
} {
if !strings.Contains(prompt, want) {
+4
View File
@@ -186,6 +186,10 @@ func validateFileList(taskPath string, field string, files []string, required bo
return problems
}
func validateExpectedChangedFiles(taskPath string, files []string) []string {
return validateFileList(taskPath, "expectedChangedFiles", files, true)
}
func validateStringList(taskPath string, field string, values []string) []string {
problems := []string{}
seen := map[string]int{}
-38
View File
@@ -1,38 +0,0 @@
// Package browser opens a URL in the user's default web browser. It is used by
// interactive flows (e.g. the provider setup wizard's OAuth login) that need to
// hand the user off to a browser for authorization.
package browser
import (
"fmt"
"os/exec"
"runtime"
)
// OpenURL launches url in the default browser. It returns once the launcher
// process has started (it does not wait for the browser to close). A failure to
// start the launcher is returned so callers can fall back to printing the URL.
func OpenURL(url string) error {
name, args := openCommand(runtime.GOOS, url)
cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("browser: open url: %w", err)
}
// Reap the short-lived launcher (open/xdg-open/rundll32 exit promptly) so it
// does not linger as a zombie; the browser window itself is independent.
go func() { _ = cmd.Wait() }()
return nil
}
// openCommand returns the launcher command + args for a platform. Split out as a
// pure function so the per-OS choice is unit-testable without spawning anything.
func openCommand(goos, url string) (string, []string) {
switch goos {
case "darwin":
return "open", []string{url}
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
default: // linux and other unixes
return "xdg-open", []string{url}
}
}
-27
View File
@@ -1,27 +0,0 @@
package browser
import (
"reflect"
"testing"
)
func TestOpenCommand(t *testing.T) {
cases := []struct {
goos string
name string
args []string
}{
{"darwin", "open", []string{"https://x.test"}},
{"linux", "xdg-open", []string{"https://x.test"}},
{"freebsd", "xdg-open", []string{"https://x.test"}},
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x.test"}},
}
for _, tc := range cases {
t.Run(tc.goos, func(t *testing.T) {
name, args := openCommand(tc.goos, "https://x.test")
if name != tc.name || !reflect.DeepEqual(args, tc.args) {
t.Fatalf("openCommand(%s) = %q %v, want %q %v", tc.goos, name, args, tc.name, tc.args)
}
})
}
}
+1 -16
View File
@@ -105,10 +105,7 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: oauthResolverForProfile(profile),
})
return providers.New(profile, providers.Options{UserAgent: userAgent()})
},
probeProviderHealth: providerhealth.Probe,
newSessionStore: func() *sessions.Store {
@@ -432,18 +429,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
needsSetup := setupRequired(resolved)
if needsSetup && !forceSetup {
// The active provider lacks a usable credential, but if another saved
// provider already has one, fall back to it instead of forcing onboarding
// again. Saved logins persist across launches; switch the active provider
// any time with `zero provider use <name>`. Onboarding only runs when no
// configured provider is usable (a genuinely fresh setup).
if usable, ok := firstUsableProvider(resolved.Providers); ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
needsSetup = false
}
}
setupVisible := forceSetup || needsSetup
configPath := ""
if setupVisible {
+4 -63
View File
@@ -5,12 +5,10 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -36,44 +34,11 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthStatus(args[1:], stdout, stderr, deps)
case "refresh":
return runAuthRefresh(args[1:], stdout, stderr, deps)
case "openrouter":
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
}
// runAuthOpenRouter runs OpenRouter's browser PKCE login and prints the freshly
// minted API key. Unlike `auth login` (which stores an OAuth bearer token),
// OpenRouter's flow mints a normal API key; the setup wizard saves it to a
// provider profile, while this command prints it for manual configuration.
func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
for _, a := range args {
if a == "-h" || a == "--help" || a == "help" {
_ = writeAuthHelp(stdout)
return exitSuccess
}
}
// openrouter takes no positional args or flags; reject the unexpected so a
// typo/unsupported flag fails fast instead of silently running the login.
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
key, err := provideroauth.OpenRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// ZERO_OPENROUTER_BASE_URL overrides the endpoint (self-hosted gateway or tests).
BaseURL: strings.TrimSpace(os.Getenv("ZERO_OPENROUTER_BASE_URL")),
})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted.\nUse it with zero, e.g.:\n export OPENROUTER_API_KEY=%s\n(or add it to a provider profile with catalogID \"openrouter\").\n", key); err != nil {
return exitCrash
}
return exitSuccess
}
// authArgs is the parsed form of an auth subcommand's arguments.
type authArgs struct {
positional []string
@@ -159,23 +124,9 @@ func validateAuthFlags(sub string, a authArgs) error {
// newAuthManager builds an oauth.Manager backed by the file store, printing the
// authorization URL / device code to stdout. The store path honors
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it. Setting
// ZERO_OAUTH_STORAGE=encrypted-file selects the AES-256-GCM encrypted-at-rest
// backend (a per-user secret is created beside the token file).
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it.
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// Validate ZERO_OAUTH_STORAGE up front: a mistyped value must fail fast rather
// than silently change the backend. Empty = default (plaintext 0600 file);
// "encrypted-file" = AES-256-GCM; "keyring" = the OS keyring.
storage := strings.ToLower(strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")))
switch storage {
case "", "file", "encrypted-file", "keyring":
default:
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: file, encrypted-file, keyring)", storage)
}
store, err := oauth.NewStore(oauth.StoreOptions{
Now: deps.now,
Storage: storage,
})
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
if err != nil {
return nil, err
}
@@ -372,13 +323,9 @@ Commands:
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
openrouter Log in to OpenRouter in the browser; mints an API key
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:
A provider is any OAuth 2.0 / OIDC server you configure via env (no providers are
built in). For a 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
@@ -386,12 +333,6 @@ below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_SCOPES ZERO_OAUTH_<NAME>_FLOW (loopback|device)
Endpoint URLs must be https (loopback exempt).
Storage: tokens are written 0600 under $XDG_CONFIG_HOME/zero (override with
ZERO_OAUTH_TOKENS_PATH). Set ZERO_OAUTH_STORAGE=encrypted-file to encrypt them at
rest with AES-256-GCM (a per-user secret beside the file), or
ZERO_OAUTH_STORAGE=keyring to use the OS keyring (macOS Keychain / Linux
secret-tool). MCP server tokens share the same store.
Flags:
--device Use the device-code flow (headless/SSH; no browser)
--scope Add an OAuth scope (repeatable)
-29
View File
@@ -17,20 +17,6 @@ func withAuthStore(t *testing.T) string {
return path
}
func TestRunAuthRejectsInvalidStorageMode(t *testing.T) {
withAuthStore(t)
// A mistyped value must fail fast, not silently fall back to plaintext while
// the user believes encryption is active.
t.Setenv("ZERO_OAUTH_STORAGE", "encryptd")
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("invalid ZERO_OAUTH_STORAGE should fail, got success; stdout=%q", stdout.String())
}
if !strings.Contains(stderr.String(), "ZERO_OAUTH_STORAGE") {
t.Fatalf("error should name the offending env var, stderr=%q", stderr.String())
}
}
func TestRunAuthStatusEmpty(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
@@ -130,21 +116,6 @@ func TestRunAuthRejectsWrongFlags(t *testing.T) {
}
}
func TestRunAuthOpenRouterRejectsArgs(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
// An unexpected arg/flag must fail fast, not silently run the login.
if code := runWithDeps([]string{"auth", "openrouter", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatalf("openrouter with an unexpected flag should fail; stdout=%q", stdout.String())
}
// --help still works.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "openrouter", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("openrouter --help should succeed, stderr=%q", stderr.String())
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
+5 -90
View File
@@ -38,8 +38,6 @@ func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int
return runDaemonAttach(rest, stdout, stderr)
case "serve-remote":
return runDaemonServeRemote(rest, stdout, stderr)
case "link":
return runDaemonLink(rest, stdout, stderr)
case "-h", "--help", "help":
return writeDaemonUsage(stdout, exitSuccess)
default:
@@ -58,20 +56,14 @@ Commands:
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
Create/route a session and stream its output.
attach <session> Attach to a running session's stream.
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f> [--bundle-dir <d>]
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f>
Serve an opt-in, TLS-only network bridge to this
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables
git-bundle uploads, extracted into per-link work trees.
link --remote <host:port> --repo <dir> --id <name> [--out <file>]
Upload repo's git history to the remote as a bundle and
print the extracted remote path. --out saves a session
link file (0600). Accepts --token/--ca-cert/--server-name.
link --show <file> Print a saved session link.
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE).
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
[--server-name <name>] to drive a remote daemon over the bridge instead of the
local socket. Use the link's remote path as --cwd to run against a linked repo.
local socket.
`)
return code
}
@@ -407,7 +399,7 @@ func daemonDialError(flags remoteDialFlags, err error) string {
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
// start without a cert/key pair and a token from the environment.
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
addr, certFile, keyFile, bundleDir := "", "", "", ""
addr, certFile, keyFile := "", "", ""
minVersion, maxConns := 0, 0
for i := 0; i < len(args); i++ {
a := args[i]
@@ -457,14 +449,6 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeExecUsageError(stderr, "--max-conns requires a value")
}
maxConns = atoiOrZero(v)
case a == "--bundle-dir":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--bundle-dir requires a value")
}
bundleDir = v
case strings.HasPrefix(a, "--bundle-dir="):
bundleDir = strings.TrimPrefix(a, "--bundle-dir=")
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
}
@@ -507,7 +491,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeAppError(stderr, err.Error(), exitCrash)
}
bridge, err := remote.NewBridge(remote.BridgeOptions{
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
@@ -540,75 +524,6 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
}
}
// runDaemonLink uploads a repo's git history to a remote bridge as a bundle
// (link without --show), or prints a saved session link (--show <file>).
func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int {
var addr, repo, id, token, caCert, serverName, out, show string
// flags maps each "--name" to the string it sets; both "--name v" and
// "--name=v" forms are accepted.
flags := map[string]*string{
"--remote": &addr, "--repo": &repo, "--id": &id, "--token": &token,
"--ca-cert": &caCert, "--server-name": &serverName, "--out": &out, "--show": &show,
}
for i := 0; i < len(args); i++ {
a := args[i]
if a == "-h" || a == "--help" {
return writeDaemonUsage(stdout, exitSuccess)
}
name, inlineVal, hasInline := a, "", false
if eq := strings.IndexByte(a, '='); eq >= 0 {
name, inlineVal, hasInline = a[:eq], a[eq+1:], true
}
dst, ok := flags[name]
if !ok {
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon link", a))
}
if hasInline {
*dst = inlineVal
continue
}
if i+1 >= len(args) {
return writeExecUsageError(stderr, fmt.Sprintf("%s requires a value", name))
}
i++
*dst = args[i]
}
if strings.TrimSpace(show) != "" {
link, err := remote.LoadSessionLink(show)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "link %s -> %s on %s\n", link.LinkID, link.RemotePath, link.Address)
return exitSuccess
}
if strings.TrimSpace(addr) == "" || strings.TrimSpace(repo) == "" || strings.TrimSpace(id) == "" {
return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show <file>)")
}
if strings.TrimSpace(token) == "" {
token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token
}
link, err := remote.UploadRepoBundle(remote.RemoteConfig{
Address: addr,
Token: token,
CACertFile: caCert,
ServerName: serverName,
}, repo, id)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "uploaded %s; remote repo at %s\n", link.LinkID, link.RemotePath)
fmt.Fprintf(stdout, "run it with: zero daemon run --remote %s --cwd %s ...\n", link.Address, link.RemotePath)
if strings.TrimSpace(out) != "" {
if err := link.Save(out); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "saved session link to %s\n", out)
}
return exitSuccess
}
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
// daemon. When Addr is empty the local control socket is used.
type remoteDialFlags struct {
-47
View File
@@ -1,47 +0,0 @@
package cli
import (
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestProviderHasOAuthLogin(t *testing.T) {
xai := config.ProviderProfile{Name: "xai", CatalogID: "xai"}
if providerHasOAuthLogin(xai, nil) {
t.Fatal("no stored login must be false")
}
if !providerHasOAuthLogin(xai, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by name must be true")
}
if !providerHasOAuthLogin(config.ProviderProfile{Name: "grok", CatalogID: "xai"}, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by catalog id must be true")
}
}
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// xai has no inline key (env-only) but IS logged in via OAuth → no setup.
loggedIn := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}}
if setupRequired(loggedIn) {
t.Fatal("a provider with a stored OAuth login must not require onboarding")
}
// A keyless provider with no OAuth login still requires setup.
noLogin := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"}}
if !setupRequired(noLogin) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
-67
View File
@@ -1,67 +0,0 @@
package cli
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil
}
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
if err != nil {
return nil
}
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
}
+2 -104
View File
@@ -3,13 +3,11 @@ package cli
import (
"fmt"
"io"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/provideronboarding"
@@ -287,108 +285,8 @@ func setupRequired(resolved config.ResolvedConfig) bool {
if !config.HasProviderProfile(resolved.Provider) {
return true
}
if _, missing := setupMissingCredentialEnv(resolved.Provider); !missing {
return false
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// though the profile has no inline key / env var — so a logged-in provider
// must not trigger onboarding.
return !providerHasOAuthLogin(resolved.Provider, oauthLoggedInProviders())
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
return false
}
// oauthLoggedInProviders returns the set of provider names that have a stored
// OAuth token, so credential checks recognize an OAuth login (not just inline
// keys / env vars). Errors degrade to an empty set (no logins).
func oauthLoggedInProviders() map[string]bool {
out := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return out
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return out
}
for _, status := range statuses {
if status.HasToken {
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
if !config.HasProviderProfile(profile) {
continue
}
// A profile whose catalog entry no longer resolves AND that has no explicit
// BaseURL has no endpoint to talk to, so it cannot become a working
// provider — skip it rather than picking a fallback that fails at first use.
// (A stale CatalogID with a BaseURL still works as a custom endpoint.)
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" && strings.TrimSpace(profile.BaseURL) == "" {
if _, err := providercatalog.Require(catalogID); err != nil {
continue
}
}
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
if !haveLocal {
localFallback = profile
haveLocal = true
}
continue
}
return profile, true
}
if haveLocal {
return localFallback, true
}
return config.ProviderProfile{}, false
}
// providerProfileIsLocal reports whether a provider points at a local endpoint
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
// remote keyed provider that is more likely reachable.
func providerProfileIsLocal(profile config.ProviderProfile) bool {
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
if descriptor, err := providercatalog.Require(catalogID); err == nil && descriptor.Local {
return true
}
}
base := strings.TrimSpace(profile.BaseURL)
if base == "" {
return false
}
parsed, err := url.Parse(base)
if err != nil {
return false
}
switch strings.ToLower(parsed.Hostname()) {
case "localhost", "127.0.0.1", "::1":
return true
}
return false
_, missing := setupMissingCredentialEnv(resolved.Provider)
return missing
}
func formatSetupComplete(result tui.SetupResult) string {
-91
View File
@@ -1,91 +0,0 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable (env only, no inline key)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "moonshot" {
t.Fatalf("want remote keyed provider (moonshot), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "ollama" {
t.Fatalf("want local usable fallback (ollama), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
}
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("no provider has a credential, want ok=false, got %q", got.Name)
}
}
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want keyless local proxy fallback, got %q ok=%v", got.Name, ok)
}
}
// A profile whose catalog entry no longer resolves and that carries no explicit
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "custom" {
t.Fatalf("want custom-endpoint fallback, got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
baseURL string
want bool
}{
{"loopback name", "http://localhost:11434/v1", true},
{"loopback v4", "http://127.0.0.1:8080", true},
{"loopback v6", "http://[::1]:10531/v1", true},
{"remote", "https://api.moonshot.ai/v1", false},
{"contains-localhost-substring", "https://notlocalhost.com/v1", false},
{"host-with-127-substring", "https://api127.0.0.1.example.com", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := providerProfileIsLocal(config.ProviderProfile{BaseURL: tc.baseURL})
if got != tc.want {
t.Fatalf("providerProfileIsLocal(%q) = %v, want %v", tc.baseURL, got, tc.want)
}
})
}
}
-37
View File
@@ -72,43 +72,6 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
model = strings.TrimSpace(model)
if model == "" {
return FileConfig{}, fmt.Errorf("model is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(cfg.Providers[index].Name, name) {
cfg.Providers[index].Model = model
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
-66
View File
@@ -150,72 +150,6 @@ func TestSetActiveProviderTightensExistingConfigFilePermissions(t *testing.T) {
}
}
func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{
Name: "openai",
ProviderKind: ProviderKindOpenAI,
APIKey: "sk-test",
Model: "gpt-4.1",
},
{
Name: "anthropic",
ProviderKind: ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
},
},
}, 0o600)
cfg, err := SetProviderModel(path, " OpenAI ", " gpt-4.1-mini ")
if err != nil {
t.Fatalf("SetProviderModel() error = %v", err)
}
if cfg.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("updated provider model = %q, want gpt-4.1-mini", cfg.Providers[0].Model)
}
if cfg.Providers[0].APIKey != "sk-test" {
t.Fatalf("provider credential was not preserved: %#v", cfg.Providers[0])
}
if cfg.Providers[1].Model != "claude-sonnet-4.5" {
t.Fatalf("unrelated provider changed: %#v", cfg.Providers[1])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", persisted.Providers[0].Model)
}
if persisted.ActiveProvider != "openai" {
t.Fatalf("active provider changed to %q", persisted.ActiveProvider)
}
}
func TestSetProviderModelRejectsUnknownProviderWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
_, err := SetProviderModel(path, "anthropic", "claude-sonnet-4.5")
if err == nil {
t.Fatal("SetProviderModel() error = nil, want error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider\nbefore: %s\nafter: %s", before, after)
}
}
func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose POSIX mode bits reliably")
-11
View File
@@ -96,23 +96,12 @@ type noopAttestation struct{}
func (noopAttestation) Verify(map[string]string) error { return nil }
// Connection modes negotiated in the auth handshake. The default (empty) is a
// daemon session, preserving the original behavior; "bundle" requests a one-shot
// git-bundle upload instead of a session.
const (
ModeSession = "session"
ModeBundle = "bundle"
)
// authRequest is the first frame a remote client sends (before the daemon
// hello). Token is never logged.
type authRequest struct {
Token string `json:"token"`
Version int `json:"version"`
Meta map[string]string `json:"meta,omitempty"`
// Mode selects the connection's purpose: "" / "session" => a daemon session
// (default), "bundle" => a git-bundle upload. Unknown modes are rejected.
Mode string `json:"mode,omitempty"`
}
// authResponse is the bridge's reply to the auth handshake.
+3 -44
View File
@@ -16,9 +16,6 @@ const (
defaultMaxConnections = 32
defaultHandshakeTimeout = 10 * time.Second
defaultAuthFailDelay = 250 * time.Millisecond
// defaultMaxBundleBytes caps a single uploaded git bundle (256 MiB) so a
// remote peer cannot exhaust the host's disk.
defaultMaxBundleBytes = 256 << 20
)
// Bridge serves authenticated remote connections and drives the local daemon's
@@ -30,8 +27,6 @@ type Bridge struct {
minVersion int
handshakeTimeout time.Duration
authFailDelay time.Duration
bundleDir string
maxBundleBytes int64
log func(string)
sem chan struct{}
@@ -56,13 +51,7 @@ type BridgeOptions struct {
HandshakeTimeout time.Duration
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
AuthFailDelay time.Duration
// BundleDir is the directory under which uploaded git bundles are extracted
// into per-link working trees. Empty disables bundle uploads entirely (a
// bundle-mode connection is then refused — opt-in, fail closed).
BundleDir string
// MaxBundleBytes caps a single uploaded bundle; 0 => default.
MaxBundleBytes int64
Log func(string)
Log func(string)
}
// NewBridge validates options and builds a Bridge.
@@ -96,10 +85,6 @@ func NewBridge(opts BridgeOptions) (*Bridge, error) {
if attest == nil {
attest = noopAttestation{}
}
maxBundleBytes := opts.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = defaultMaxBundleBytes
}
return &Bridge{
server: opts.Server,
auth: opts.Authenticator,
@@ -107,8 +92,6 @@ func NewBridge(opts BridgeOptions) (*Bridge, error) {
minVersion: minVersion,
handshakeTimeout: handshakeTimeout,
authFailDelay: authFailDelay,
bundleDir: strings.TrimSpace(opts.BundleDir),
maxBundleBytes: maxBundleBytes,
log: opts.Log,
sem: make(chan struct{}, maxConns),
}, nil
@@ -196,37 +179,13 @@ func (b *Bridge) handle(conn net.Conn) {
b.deny(conn, "attestation failed")
return
}
// Resolve the connection mode before accepting it. Validating here (before the
// success response) lets the bridge fail closed: an unknown mode, or a bundle
// upload when bundle transfer is disabled, is denied without side effects.
mode := req.Mode
if mode == "" {
mode = ModeSession
}
switch mode {
case ModeSession:
case ModeBundle:
if b.bundleDir == "" {
b.deny(conn, "bundle transfer not enabled")
return
}
default:
b.deny(conn, "unsupported mode")
return
}
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
_ = conn.Close()
return
}
// Clear the handshake deadline: a session may stream, and a bundle upload may
// transfer many megabytes, for a long time.
// Clear the handshake deadline: a session may stream for a long time.
_ = conn.SetDeadline(time.Time{})
switch mode {
case ModeBundle:
b.handleBundle(conn) // receives + extracts the bundle, then closes conn
default:
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
}
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
}
// deny rejects an unauthenticated connection after a small backoff (to slow
-378
View File
@@ -1,378 +0,0 @@
package remote
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
// gitTimeout bounds a single git invocation (bundle create/verify, clone) so a
// hung git process cannot pin a connection or the upload path indefinitely.
const gitTimeout = 2 * time.Minute
// bundleChunkSize is the per-frame payload when streaming a bundle file. It is
// kept comfortably under daemon.MaxFrameSize (1 MiB).
const bundleChunkSize = 512 << 10
// bundleHeader is the first control frame of a bundle upload (after the auth
// handshake): it declares the link id and the exact byte size that follows.
type bundleHeader struct {
LinkID string `json:"link_id"`
Size int64 `json:"size"`
}
// bundleResult is the bridge's reply once the bundle is received and extracted.
type bundleResult struct {
OK bool `json:"ok"`
Path string `json:"path,omitempty"`
Message string `json:"message,omitempty"`
}
func writeBundleHeader(w io.Writer, h bundleHeader) error {
payload, err := json.Marshal(h)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleHeader(r io.Reader) (bundleHeader, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleHeader{}, err
}
if kind != daemon.KindCtrl {
return bundleHeader{}, errors.New("remote: expected bundle header frame")
}
var h bundleHeader
if err := json.Unmarshal(payload, &h); err != nil {
return bundleHeader{}, fmt.Errorf("remote: decode bundle header: %w", err)
}
return h, nil
}
func writeBundleResult(w io.Writer, res bundleResult) error {
payload, err := json.Marshal(res)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleResult(r io.Reader) (bundleResult, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleResult{}, err
}
if kind != daemon.KindCtrl {
return bundleResult{}, errors.New("remote: expected bundle result frame")
}
var res bundleResult
if err := json.Unmarshal(payload, &res); err != nil {
return bundleResult{}, fmt.Errorf("remote: decode bundle result: %w", err)
}
return res, nil
}
// ---- server side -----------------------------------------------------------
// handleBundle receives an uploaded bundle, extracts it into a per-link working
// tree, and reports the outcome. It always closes conn.
func (b *Bridge) handleBundle(conn net.Conn) {
defer func() { _ = conn.Close() }()
res := b.receiveBundle(conn)
if !res.OK {
b.logf("remote: bundle upload rejected: %s", res.Message)
}
_ = writeBundleResult(conn, res)
}
// receiveBundle reads the header + framed bundle bytes, verifies the bundle, and
// extracts it under bundleDir. Every failure returns a non-OK result rather than
// panicking, and the staged temp file is always removed.
func (b *Bridge) receiveBundle(conn net.Conn) bundleResult {
hdr, err := readBundleHeader(conn)
if err != nil {
return bundleResult{Message: "read bundle header: " + err.Error()}
}
id, err := sanitizeLinkID(hdr.LinkID)
if err != nil {
return bundleResult{Message: err.Error()}
}
if hdr.Size <= 0 || hdr.Size > b.maxBundleBytes {
return bundleResult{Message: fmt.Sprintf("invalid bundle size %d (max %d)", hdr.Size, b.maxBundleBytes)}
}
tmp, err := os.CreateTemp("", "zero-remote-*.bundle")
if err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := streamFramesToFile(conn, tmp, hdr.Size); err != nil {
_ = tmp.Close()
return bundleResult{Message: "receive bundle: " + err.Error()}
}
if err := tmp.Close(); err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleVerify(ctx, tmpName); err != nil {
return bundleResult{Message: "bundle verify: " + err.Error()}
}
dest := filepath.Join(b.bundleDir, id)
if !withinDir(b.bundleDir, dest) {
return bundleResult{Message: "invalid link id"}
}
if err := extractBundle(ctx, tmpName, dest); err != nil {
return bundleResult{Message: "extract bundle: " + err.Error()}
}
return bundleResult{OK: true, Path: dest}
}
// streamFramesToFile copies exactly size bytes from KindData frames on r into w.
// A non-data frame, or any frame that would overrun the declared size, is an
// error (fail closed) so a peer cannot write past the cap.
func streamFramesToFile(r io.Reader, w io.Writer, size int64) error {
remaining := size
for remaining > 0 {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return err
}
if kind != daemon.KindData {
return errors.New("expected bundle data frame")
}
if int64(len(payload)) > remaining {
return errors.New("bundle exceeds declared size")
}
if _, err := w.Write(payload); err != nil {
return err
}
remaining -= int64(len(payload))
}
return nil
}
// extractBundle clones bundleFile into a staging dir, then atomically renames it
// over dest (replacing any prior extraction for this link id). git clone needs a
// non-existent target, so the staging+rename keeps the live dest intact on error.
func extractBundle(ctx context.Context, bundleFile, dest string) error {
parent := filepath.Dir(dest)
if err := os.MkdirAll(parent, 0o700); err != nil {
return err
}
staging, err := os.MkdirTemp(parent, ".staging-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(staging) }()
cloneDest := filepath.Join(staging, "repo")
if err := gitClone(ctx, bundleFile, cloneDest); err != nil {
return err
}
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(cloneDest, dest)
}
// ---- client side -----------------------------------------------------------
// UploadRepoBundle creates a git bundle of repoDir's full history and uploads it
// to the remote bridge over an authenticated, bundle-mode TLS connection. The
// bridge extracts it into a per-link working tree and returns its path, captured
// in the returned SessionLink. repoDir must be a git work tree.
func UploadRepoBundle(cfg RemoteConfig, repoDir, linkID string) (*SessionLink, error) {
id, err := sanitizeLinkID(linkID)
if err != nil {
return nil, err
}
repoDir = strings.TrimSpace(repoDir)
if repoDir == "" {
return nil, errors.New("remote: repo dir is required")
}
if !isGitWorktree(repoDir) {
return nil, fmt.Errorf("remote: %s is not a git repository", repoDir)
}
// Reserve a unique temp name, then let git create the bundle fresh at it.
tmp, err := os.CreateTemp("", "zero-bundle-*.bundle")
if err != nil {
return nil, fmt.Errorf("remote: stage bundle: %w", err)
}
tmpName := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpName)
defer func() { _ = os.Remove(tmpName) }()
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleCreate(ctx, repoDir, tmpName); err != nil {
return nil, fmt.Errorf("remote: create bundle: %w", err)
}
sum, size, err := hashFile(tmpName)
if err != nil {
return nil, fmt.Errorf("remote: hash bundle: %w", err)
}
conn, err := dialAuthenticated(cfg, ModeBundle)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if err := writeBundleHeader(conn, bundleHeader{LinkID: id, Size: size}); err != nil {
return nil, fmt.Errorf("remote: send bundle header: %w", err)
}
if err := streamFileFrames(conn, tmpName); err != nil {
return nil, fmt.Errorf("remote: send bundle: %w", err)
}
res, err := readBundleResult(conn)
if err != nil {
return nil, fmt.Errorf("remote: bundle result: %w", err)
}
if !res.OK {
return nil, fmt.Errorf("remote: bundle rejected: %s", res.Message)
}
return &SessionLink{
Address: strings.TrimSpace(cfg.Address),
ServerName: strings.TrimSpace(cfg.ServerName),
CACertFile: strings.TrimSpace(cfg.CACertFile),
LinkID: id,
RemotePath: res.Path,
BundleSHA256: sum,
}, nil
}
// streamFileFrames writes the file at path to w as a sequence of KindData frames.
func streamFileFrames(w io.Writer, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
buf := make([]byte, bundleChunkSize)
for {
n, err := f.Read(buf)
if n > 0 {
if werr := daemon.WriteFrame(w, daemon.KindData, buf[:n]); werr != nil {
return werr
}
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
// ---- git + path helpers ----------------------------------------------------
func gitBundleCreate(ctx context.Context, repoDir, outFile string) error {
return runGit(ctx, repoDir, "bundle", "create", outFile, "--all")
}
func gitBundleVerify(ctx context.Context, bundleFile string) error {
return runGit(ctx, "", "bundle", "verify", bundleFile)
}
func gitClone(ctx context.Context, bundleFile, destDir string) error {
return runGit(ctx, "", "clone", "--quiet", bundleFile, destDir)
}
// isGitWorktree reports whether dir is inside a git work tree.
func isGitWorktree(dir string) bool {
cmd := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
out, err := cmd.CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) == "true"
}
// runGit runs a git subcommand, returning a concise single-line error on failure.
func runGit(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
msg := firstLine(strings.TrimSpace(string(out)))
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("git %s: %s", args[0], msg)
}
return nil
}
// sanitizeLinkID validates a link id used as a single path component under the
// bundle dir. It allows letters, digits, '-', '_', '.', forbids the traversal
// names, and caps the length — so it can never escape the bundle dir.
func sanitizeLinkID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", errors.New("remote: link id is required")
}
if len(id) > 128 {
return "", errors.New("remote: link id too long (max 128)")
}
if id == "." || id == ".." {
return "", errors.New("remote: invalid link id")
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
default:
return "", errors.New("remote: link id may only contain letters, digits, '-', '_', '.'")
}
}
return id, nil
}
// withinDir reports whether target resolves to a path inside root.
func withinDir(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// hashFile returns the hex SHA-256 and byte size of the file at path.
func hashFile(path string) (sum string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
-193
View File
@@ -1,193 +0,0 @@
package remote
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// initTestRepo creates a temp git work tree with one committed file and returns
// its path. It sets a deterministic identity so it does not depend on global git
// config.
func initTestRepo(t *testing.T, file, content string) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init", "-q")
run("config", "user.email", "[email protected]")
run("config", "user.name", "t")
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "init")
return dir
}
func TestGitBundleRoundTrip(t *testing.T) {
repo := initTestRepo(t, "a.txt", "content")
ctx := context.Background()
bundle := filepath.Join(t.TempDir(), "x.bundle")
if err := gitBundleCreate(ctx, repo, bundle); err != nil {
t.Fatalf("create: %v", err)
}
if err := gitBundleVerify(ctx, bundle); err != nil {
t.Fatalf("verify: %v", err)
}
dest := filepath.Join(t.TempDir(), "clone")
if err := gitClone(ctx, bundle, dest); err != nil {
t.Fatalf("clone: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "a.txt")); err != nil {
t.Fatalf("cloned tree missing file: %v", err)
}
}
func TestIsGitWorktree(t *testing.T) {
repo := initTestRepo(t, "f", "x")
if !isGitWorktree(repo) {
t.Fatal("a git repo should be detected")
}
if isGitWorktree(t.TempDir()) {
t.Fatal("a plain dir should not be detected as a git repo")
}
}
func TestSanitizeLinkID(t *testing.T) {
for _, ok := range []string{"proj", "proj-1", "a_b.c", "ABC123"} {
if _, err := sanitizeLinkID(ok); err != nil {
t.Fatalf("sanitizeLinkID(%q) unexpected error: %v", ok, err)
}
}
for _, bad := range []string{"", " ", ".", "..", "a/b", "a\\b", "a b", "a$b", string(make([]byte, 200))} {
if _, err := sanitizeLinkID(bad); err == nil {
t.Fatalf("sanitizeLinkID(%q) should error", bad)
}
}
}
func TestWithinDir(t *testing.T) {
root := t.TempDir()
if !withinDir(root, filepath.Join(root, "child")) {
t.Fatal("child should be within root")
}
if withinDir(root, filepath.Dir(root)) {
t.Fatal("parent should not be within root")
}
}
func TestSessionLinkSaveLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "link.json")
link := SessionLink{Address: "host:9000", ServerName: "host", LinkID: "proj-1", RemotePath: "/bundles/proj-1", BundleSHA256: "deadbeef"}
if err := link.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("link file perm = %v, want 0600", perm)
}
}
got, err := LoadSessionLink(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if *got != link {
t.Fatalf("roundtrip mismatch: %+v != %+v", *got, link)
}
}
func TestSessionLinkValidate(t *testing.T) {
for _, bad := range []SessionLink{
{LinkID: "p", RemotePath: "/r"}, // no address
{Address: "h:1", RemotePath: "/r"}, // no link id
{Address: "h:1", LinkID: "p"}, // no remote path
} {
if err := bad.Validate(); err == nil {
t.Fatalf("Validate(%+v) should error", bad)
}
if err := bad.Save(filepath.Join(t.TempDir(), "x.json")); err == nil {
t.Fatalf("Save of invalid link %+v should error", bad)
}
}
}
func TestUploadRepoBundleRejectsNonRepo(t *testing.T) {
// A non-git dir is rejected before any dial, so the bogus address is never used.
_, err := UploadRepoBundle(RemoteConfig{Address: "127.0.0.1:1", Token: "t"}, t.TempDir(), "p")
if err == nil {
t.Fatal("a non-git dir should be rejected")
}
}
func TestBridgeBundleUploadRoundTrip(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
bundleRoot := t.TempDir()
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: bundleRoot})
repo := initTestRepo(t, "hello.txt", "hi there")
link, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1")
if err != nil {
t.Fatalf("UploadRepoBundle: %v", err)
}
wantPath := filepath.Join(bundleRoot, "proj-1")
if link.RemotePath != wantPath {
t.Fatalf("remote path = %q, want %q", link.RemotePath, wantPath)
}
if link.BundleSHA256 == "" {
t.Fatal("link should carry a bundle sha256")
}
// The extracted work tree should contain the committed file.
data, err := os.ReadFile(filepath.Join(link.RemotePath, "hello.txt"))
if err != nil {
t.Fatalf("extracted tree missing file: %v", err)
}
if string(data) != "hi there" {
t.Fatalf("extracted file content = %q", data)
}
// A second upload to the same link id replaces the prior extraction.
if _, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1"); err != nil {
t.Fatalf("re-upload: %v", err)
}
}
func TestBridgeBundleDisabledByDefault(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1}) // no BundleDir
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload must be refused when --bundle-dir is unset")
}
}
func TestBridgeBundleRejectsBadToken(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("correct")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: t.TempDir(), AuthFailDelay: -1})
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload with a bad token must be refused")
}
}
+2 -19
View File
@@ -37,23 +37,6 @@ type RemoteConfig struct {
// share one protocol. The server certificate is always verified (never
// InsecureSkipVerify).
func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
conn, err := dialAuthenticated(cfg, ModeSession)
if err != nil {
return nil, err
}
client, err := daemon.NewClientConn(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
return client, nil
}
// dialAuthenticated establishes a verified TLS connection, performs the
// bearer-token auth handshake for the given mode, and returns the live conn with
// its deadline cleared (ready for the subsequent daemon handshake or bundle
// stream). The server certificate is always verified (never InsecureSkipVerify).
func dialAuthenticated(cfg RemoteConfig, mode string) (net.Conn, error) {
address := strings.TrimSpace(cfg.Address)
if address == "" {
return nil, errors.New("remote: address is required")
@@ -82,7 +65,7 @@ func dialAuthenticated(cfg RemoteConfig, mode string) (net.Conn, error) {
}
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
_ = conn.SetDeadline(time.Now().Add(timeout))
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion, Mode: mode}); err != nil {
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("remote: send auth: %w", err)
}
@@ -96,7 +79,7 @@ func dialAuthenticated(cfg RemoteConfig, mode string) (net.Conn, error) {
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
}
_ = conn.SetDeadline(time.Time{})
return conn, nil
return daemon.NewClientConn(conn)
}
// clientTLSConfig builds a verifying client TLS config. It never disables
-107
View File
@@ -1,107 +0,0 @@
package remote
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// SessionLink records the association between a local repo and the remote
// working tree a bundle upload produced. It carries everything needed to reach
// the linked repo again — the bridge address, TLS verification details, the link
// id, and the extracted remote path — except the bearer token, which is always
// supplied separately (never persisted to the link file).
type SessionLink struct {
// Address is host:port of the remote bridge.
Address string `json:"address"`
// ServerName overrides TLS/SNI verification; empty => host of Address.
ServerName string `json:"server_name,omitempty"`
// CACertFile is the CA trusted for the bridge cert (for a self-signed bridge).
CACertFile string `json:"ca_cert_file,omitempty"`
// LinkID is the per-link identifier (a single path component on the remote).
LinkID string `json:"link_id"`
// RemotePath is the extracted working tree on the remote; use it as --cwd for
// a remote run/attach against the linked repo.
RemotePath string `json:"remote_path"`
// BundleSHA256 is the hex SHA-256 of the uploaded bundle, for verification.
BundleSHA256 string `json:"bundle_sha256,omitempty"`
}
// Validate checks the fields required to use a link.
func (l SessionLink) Validate() error {
if strings.TrimSpace(l.Address) == "" {
return errors.New("remote: session link address is required")
}
if strings.TrimSpace(l.LinkID) == "" {
return errors.New("remote: session link id is required")
}
if strings.TrimSpace(l.RemotePath) == "" {
return errors.New("remote: session link remote path is required")
}
return nil
}
// Save writes the link to path as pretty JSON with 0600 permissions, atomically
// (write-temp-then-rename) so a reader never sees a partial file.
func (l SessionLink) Save(path string) error {
if err := l.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return atomicWriteFile(path, append(data, '\n'), 0o600)
}
// LoadSessionLink reads and validates a link file written by Save.
func LoadSessionLink(path string) (*SessionLink, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var l SessionLink
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("remote: decode session link: %w", err)
}
if err := l.Validate(); err != nil {
return nil, err
}
return &l, nil
}
// atomicWriteFile writes data to path via a temp file in the same directory,
// chmod-ed to perm, then renamed into place.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".link-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
cleanup = false
return nil
}
+4 -57
View File
@@ -10,8 +10,6 @@ import (
"testing"
)
const minimalPDFTextChunkSize = 80
// buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws
// the given text. It computes a real cross-reference table and trailer so a
// pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test
@@ -30,11 +28,10 @@ func buildMinimalPDF(text string) []byte {
startObj() // object 2: page tree
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
pageHeight := minimalPDFPageHeight(text)
startObj() // object 3: page
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
content := minimalPDFTextContent(text, pageHeight-92)
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
startObj() // object 4: content stream
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
buf.WriteString(content)
@@ -55,56 +52,6 @@ func buildMinimalPDF(text string) []byte {
return buf.Bytes()
}
func minimalPDFPageHeight(text string) int {
lines := (len(text) + minimalPDFTextChunkSize - 1) / minimalPDFTextChunkSize
if lines < 1 {
lines = 1
}
height := 184 + lines*10
if height < 792 {
return 792
}
return height
}
func minimalPDFTextContent(text string, startY int) string {
var content strings.Builder
content.WriteString("BT /F1 8 Tf 10 TL 72 ")
content.WriteString(strconv.Itoa(startY))
content.WriteString(" Td ")
for index := 0; len(text) > 0; index++ {
if index > 0 {
content.WriteString(" T* ")
}
chunk := text
if len(chunk) > minimalPDFTextChunkSize {
cut := minimalPDFTextChunkSize
for cut > 0 && !utf8RuneStart(chunk[cut]) {
cut--
}
if cut == 0 {
cut = minimalPDFTextChunkSize
}
chunk = text[:cut]
}
content.WriteString("(")
content.WriteString(escapePDFLiteral(chunk))
content.WriteString(") Tj")
text = text[len(chunk):]
}
content.WriteString(" ET")
return content.String()
}
func escapePDFLiteral(text string) string {
text = strings.ReplaceAll(text, `\`, `\\`)
text = strings.ReplaceAll(text, `(`, `\(`)
text = strings.ReplaceAll(text, `)`, `\)`)
text = strings.ReplaceAll(text, "\r", `\r`)
text = strings.ReplaceAll(text, "\n", `\n`)
return text
}
func TestIsPDF(t *testing.T) {
if !isPDF(buildMinimalPDF("hi")) {
t.Fatal("isPDF should accept real %PDF- bytes")
@@ -212,7 +159,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
// Many short lines so the *extracted text* (not the file) exceeds the cap.
var body strings.Builder
line := "The quick brown fox jumps over the lazy dog. "
for body.Len() < MaxDocumentTextBytes+65536 {
for body.Len() < MaxDocumentTextBytes+4096 {
body.WriteString(line)
}
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
@@ -226,7 +173,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes)
}
if !doc.Truncated {
t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages)
t.Fatal("Truncated should be set when extracted text is capped")
}
if !strings.Contains(doc.Text, documentTruncatedMarker) {
t.Fatal("truncated text should carry the truncation marker")
-225
View File
@@ -1,225 +0,0 @@
// Package keyring provides a small, dependency-free secret store backed by the
// operating system's native credential tooling: the `security` keychain CLI on
// macOS and `secret-tool` (libsecret) on Linux. It stores a single secret string
// per (service, account). Windows and other platforms report unsupported.
//
// It shells out to the OS tools rather than taking a third-party dependency. On
// macOS the secret is passed to `security` as an argument, so it is briefly
// visible to other processes via the process list; on Linux the secret is passed
// over stdin and is not exposed in the argument vector. Callers that need to keep
// a secret out of the process list on macOS should prefer the file backend.
package keyring
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
)
// commandTimeout bounds a single keyring tool invocation.
const commandTimeout = 10 * time.Second
// ErrUnsupported is returned when no OS keyring backend is available for the
// current platform.
var ErrUnsupported = errors.New("keyring: no OS keyring backend on this platform")
// runner executes name with args and optional stdin, returning stdout. It is the
// single seam tests replace to drive the platform command logic without touching
// a real keychain.
type runner func(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error)
// Keyring is an OS-native secret store.
type Keyring struct {
run runner
goos string
}
// New returns a Keyring for the current platform.
func New() *Keyring {
return &Keyring{run: execRunner, goos: runtime.GOOS}
}
// Available reports whether this platform has a supported keyring backend. The
// backing tool (`secret-tool` on Linux) must also be installed; a missing tool
// surfaces as an error from Get/Set/Delete.
func (k *Keyring) Available() bool {
switch k.goos {
case "darwin", "linux":
return true
default:
return false
}
}
// Set stores secret under (service, account), replacing any existing value.
func (k *Keyring) Set(service, account, secret string) error {
if err := validate(service, account); err != nil {
return err
}
switch k.goos {
case "darwin":
// -U updates the item if it already exists rather than failing.
_, err := k.exec(nil, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
return wrap("set", err)
case "linux":
// secret-tool reads the secret from stdin, keeping it out of the argv.
_, err := k.exec([]byte(secret), "secret-tool", "store", "--label", "zero", "service", service, "account", account)
return wrap("set", err)
default:
return ErrUnsupported
}
}
// Get returns the secret stored under (service, account). The bool is false when
// no entry exists (which is not an error).
func (k *Keyring) Get(service, account string) (string, bool, error) {
if err := validate(service, account); err != nil {
return "", false, err
}
switch k.goos {
case "darwin":
out, err := k.exec(nil, "security", "find-generic-password", "-s", service, "-a", account, "-w")
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
return strings.TrimRight(string(out), "\r\n"), true, nil
case "linux":
out, err := k.exec(nil, "secret-tool", "lookup", "service", service, "account", account)
if err != nil {
if isNotFound(err, secretToolNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
value := strings.TrimRight(string(out), "\r\n")
if value == "" {
return "", false, nil
}
return value, true, nil
default:
return "", false, ErrUnsupported
}
}
// Delete removes the entry under (service, account), reporting whether one
// existed.
func (k *Keyring) Delete(service, account string) (bool, error) {
if err := validate(service, account); err != nil {
return false, err
}
switch k.goos {
case "darwin":
_, err := k.exec(nil, "security", "delete-generic-password", "-s", service, "-a", account)
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return false, nil
}
return false, wrap("delete", err)
}
return true, nil
case "linux":
// `secret-tool clear` always exits 0, so probe existence first.
_, existed, err := k.Get(service, account)
if err != nil {
return false, err
}
if _, err := k.exec(nil, "secret-tool", "clear", "service", service, "account", account); err != nil {
return false, wrap("delete", err)
}
return existed, nil
default:
return false, ErrUnsupported
}
}
func (k *Keyring) exec(stdin []byte, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
return k.run(ctx, name, stdin, args...)
}
func execRunner(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
if len(stdin) > 0 {
cmd.Stdin = bytes.NewReader(stdin)
}
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return out.Bytes(), &runError{err: err, stderr: strings.TrimSpace(errBuf.String())}
}
return out.Bytes(), nil
}
// runError carries a tool failure with its stderr and preserves the underlying
// error for errors.As (so exit-code / not-found detection still works).
type runError struct {
err error
stderr string
}
func (e *runError) Error() string {
if e.stderr != "" {
return e.stderr
}
return e.err.Error()
}
func (e *runError) Unwrap() error { return e.err }
// Not-found exit codes for the OS tools: macOS `security` exits 44
// (errSecItemNotFound) when no matching item exists; `secret-tool` exits 1 when a
// lookup finds nothing. Any other non-zero exit is a real failure.
const (
securityNotFoundExit = 44
secretToolNotFoundExit = 1
)
// isNotFound reports whether err is a tool exit whose code is one of the given
// "no such entry" codes (as opposed to a missing binary or a genuine failure,
// which must not be masked). It matches on the ExitCode behavior (satisfied by
// *exec.ExitError) so the logic is testable without spawning a real process.
func isNotFound(err error, codes ...int) bool {
var coder interface{ ExitCode() int }
if !errors.As(err, &coder) {
return false
}
code := coder.ExitCode()
for _, c := range codes {
if code == c {
return true
}
}
return false
}
// wrap adds operation context to a tool error, leaving nil untouched.
func wrap(op string, err error) error {
if err == nil {
return nil
}
var execErr *exec.Error
if errors.As(err, &execErr) {
return fmt.Errorf("keyring: %s: %q not found (install the OS keyring tool): %w", op, execErr.Name, err)
}
return fmt.Errorf("keyring: %s: %w", op, err)
}
func validate(service, account string) error {
if strings.TrimSpace(service) == "" {
return errors.New("keyring: service is required")
}
if strings.TrimSpace(account) == "" {
return errors.New("keyring: account is required")
}
return nil
}
-216
View File
@@ -1,216 +0,0 @@
package keyring
import (
"context"
"os/exec"
"strings"
"testing"
)
// fakeExit is a not-found exit error: it satisfies the ExitCode() seam used by
// isNotFound without spawning a real process.
type fakeExit struct{ code int }
func (e fakeExit) Error() string { return "exit status" }
func (e fakeExit) ExitCode() int { return e.code }
// fakeKeyring is an in-memory simulation of the OS tools driven through the
// runner seam. It records the last stdin so tests can assert the secret never
// travels via argv on Linux.
type fakeKeyring struct {
goos string
data map[string]string
lastStdin string
lastArgs []string
}
func newFake(goos string) *fakeKeyring {
return &fakeKeyring{goos: goos, data: map[string]string{}}
}
func (f *fakeKeyring) keyring() *Keyring { return &Keyring{run: f.run, goos: f.goos} }
func flagValue(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func attrValue(args []string, attr string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == attr {
return args[i+1]
}
}
return ""
}
func key(service, account string) string { return service + "\x00" + account }
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
f.lastStdin = string(stdin)
f.lastArgs = append([]string{name}, args...)
if len(args) == 0 {
return nil, fakeExit{1}
}
switch f.goos {
case "darwin":
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
switch args[0] {
case "add-generic-password":
f.data[key(svc, acct)] = flagValue(args, "-w")
return nil, nil
case "find-generic-password":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v + "\n"), nil // security prints a trailing newline
}
return nil, fakeExit{44}
case "delete-generic-password":
if _, ok := f.data[key(svc, acct)]; ok {
delete(f.data, key(svc, acct))
return nil, nil
}
return nil, fakeExit{44}
}
case "linux":
svc, acct := attrValue(args, "service"), attrValue(args, "account")
switch args[0] {
case "store":
f.data[key(svc, acct)] = string(stdin)
return nil, nil
case "lookup":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v), nil // secret-tool prints no trailing newline
}
return nil, fakeExit{1}
case "clear":
delete(f.data, key(svc, acct))
return nil, nil
}
}
return nil, fakeExit{1}
}
func TestKeyringGetSurfacesNonNotFoundError(t *testing.T) {
// On macOS only exit 44 (errSecItemNotFound) means "no entry"; any other
// non-zero exit is a real failure that must surface, not be masked as absent.
k := &Keyring{
goos: "darwin",
run: func(_ context.Context, _ string, _ []byte, _ ...string) ([]byte, error) {
return nil, fakeExit{1}
},
}
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("a non-44 exit must surface as an error, got ok=%v err=%v", ok, err)
}
}
func TestKeyringRoundTripDarwin(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("zero", "tokens", "blob-AAA"); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
if got != "blob-AAA" {
t.Fatalf("Get = %q, want blob-AAA", got)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
if _, ok, _ := k.Get("zero", "tokens"); ok {
t.Fatal("token should be gone after delete")
}
}
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
f := newFake("linux")
k := f.keyring()
if err := k.Set("zero", "tokens", "blob-BBB"); err != nil {
t.Fatalf("Set: %v", err)
}
// The secret must travel via stdin, never the argument vector.
if f.lastStdin != "blob-BBB" {
t.Fatalf("secret not sent via stdin: stdin=%q", f.lastStdin)
}
for _, a := range f.lastArgs {
if strings.Contains(a, "blob-BBB") {
t.Fatalf("secret leaked into argv: %v", f.lastArgs)
}
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != "blob-BBB" {
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
}
func TestKeyringGetMissingIsNotError(t *testing.T) {
for _, goos := range []string{"darwin", "linux"} {
k := newFake(goos).keyring()
if _, ok, err := k.Get("zero", "absent"); err != nil || ok {
t.Fatalf("[%s] Get(absent) = ok=%v err=%v, want false/nil", goos, ok, err)
}
if existed, err := k.Delete("zero", "absent"); err != nil || existed {
t.Fatalf("[%s] Delete(absent) = existed=%v err=%v, want false/nil", goos, existed, err)
}
}
}
func TestKeyringUnsupportedPlatform(t *testing.T) {
k := &Keyring{run: newFake("windows").run, goos: "windows"}
if k.Available() {
t.Fatal("windows should report unavailable")
}
if err := k.Set("zero", "tokens", "x"); err == nil {
t.Fatal("Set on unsupported platform should error")
}
if _, _, err := k.Get("zero", "tokens"); err == nil {
t.Fatal("Get on unsupported platform should error")
}
if _, err := k.Delete("zero", "tokens"); err == nil {
t.Fatal("Delete on unsupported platform should error")
}
}
func TestKeyringValidation(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("", "a", "s"); err == nil {
t.Fatal("empty service should error")
}
if err := k.Set("svc", "", "s"); err == nil {
t.Fatal("empty account should error")
}
}
func TestKeyringMissingBinaryError(t *testing.T) {
// A missing tool surfaces as a wrapped, descriptive error (not not-found).
k := &Keyring{goos: "linux", run: func(context.Context, string, []byte, ...string) ([]byte, error) {
return nil, &exec.Error{Name: "secret-tool", Err: exec.ErrNotFound}
}}
if err := k.Set("zero", "tokens", "x"); err == nil || !strings.Contains(err.Error(), "secret-tool") {
t.Fatalf("missing-binary Set error = %v, want mention of secret-tool", err)
}
// A missing binary on Get must not be misread as not-found.
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("missing-binary Get = ok=%v err=%v, want error", ok, err)
}
}
func TestAvailable(t *testing.T) {
if !(newFake("darwin").keyring().Available()) {
t.Fatal("darwin should be available")
}
if !(newFake("linux").keyring().Available()) {
t.Fatal("linux should be available")
}
}
+170 -129
View File
@@ -6,15 +6,17 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
// retained so migration can recognize a file it understands.
const tokenStoreSchemaVersion = 1
const (
tokenStoreSchemaVersion = 1
tokenStoreLockTimeout = 5 * time.Second
tokenStoreLockRetry = 10 * time.Millisecond
)
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
// for a single MCP server. The token fields are sensitive: they are tagged so
@@ -40,37 +42,29 @@ type TokenStatus struct {
Expired bool `json:"expired"`
}
// TokenStoreOptions configures the unified token store backing MCP OAuth tokens.
// FilePath overrides the store path (default: the shared oauth store path).
// LegacyPath overrides the pre-unification file migrated on construction; when
// empty it defaults to the conventional mcp-oauth-tokens.json only for the
// default (FilePath-unset) store, so an explicit FilePath never triggers an
// unexpected migration from the real user config.
// TokenStoreOptions configures where OAuth tokens are persisted. When FilePath
// is empty the path is resolved under the user config dir, mirroring how MCP
// permissions are stored.
type TokenStoreOptions struct {
FilePath string
LegacyPath string
Env map[string]string
Now func() time.Time
FilePath string
Env map[string]string
Now func() time.Time
}
// TokenStore persists MCP OAuth tokens in the unified oauth store
// (internal/oauth) under the "mcp:" namespace, sharing one file with provider
// logins. On construction it transparently and non-destructively migrates a
// legacy mcp-oauth-tokens.json into the unified store.
// TokenStore persists OAuth tokens per MCP server in a 0600 JSON file.
type TokenStore struct {
store *oauth.Store
filePath string
now func() time.Time
mu sync.Mutex
}
// tokenFile is the legacy on-disk format, retained only to read a
// pre-unification mcp-oauth-tokens.json during migration.
type tokenFile struct {
SchemaVersion int `json:"schemaVersion"`
Tokens map[string]StoredToken `json:"tokens"`
}
// ResolveTokenStorePath determines the on-disk location of the LEGACY OAuth token
// file, honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
// It is used to locate a pre-unification file for migration.
// ResolveTokenStorePath determines the on-disk location for OAuth tokens,
// honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
func ResolveTokenStorePath(env map[string]string) (string, error) {
override := strings.TrimSpace(envValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"))
if override != "" {
@@ -101,168 +95,215 @@ func ResolveTokenStorePath(env map[string]string) (string, error) {
return filepath.Join(configHome, "zero", "mcp-oauth-tokens.json"), nil
}
// NewTokenStore builds the unified-store-backed token store and runs a one-time
// migration from a legacy file when applicable.
// NewTokenStore builds a file-backed token store.
func NewTokenStore(options TokenStoreOptions) (*TokenStore, error) {
now := options.Now
if now == nil {
now = time.Now
}
unified, err := oauth.NewStore(oauth.StoreOptions{
FilePath: options.FilePath,
Env: options.Env,
Now: now,
})
if err != nil {
return nil, err
}
store := &TokenStore{store: unified}
legacyPath := strings.TrimSpace(options.LegacyPath)
if legacyPath == "" && strings.TrimSpace(options.FilePath) == "" {
// Default (production) construction: migrate from the conventional path.
legacyPath, err = ResolveTokenStorePath(options.Env)
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveTokenStorePath(options.Env)
if err != nil {
return nil, err
}
}
if legacyPath != "" {
if err := store.migrateLegacy(legacyPath); err != nil {
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return nil, err
}
}
return store, nil
now := options.Now
if now == nil {
now = time.Now
}
return &TokenStore{filePath: filepath.Clean(filePath), now: now}, nil
}
// FilePath returns the resolved unified store path.
// FilePath returns the resolved token store path.
func (store *TokenStore) FilePath() string {
return store.store.FilePath()
return store.filePath
}
// Save persists the token for a server, replacing any existing entry.
func (store *TokenStore) Save(serverName string, token StoredToken) error {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
if err != nil {
return err
}
return store.store.Save(key, storedToOAuth(token))
defer unlock()
state, err := store.readState()
if err != nil {
return err
}
state.Tokens[serverName] = token
return store.writeState(state)
}
// Load returns the stored token for a server. The second return value is false
// when no token has been stored for the server.
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return StoredToken{}, false, err
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return StoredToken{}, false, err
}
token, ok, err := store.store.Load(key)
if err != nil || !ok {
return StoredToken{}, ok, err
}
return tokenToStored(token), true, nil
token, ok := state.Tokens[serverName]
return token, ok, nil
}
// Delete removes the stored token for a server. It reports whether an entry was
// present before deletion.
func (store *TokenStore) Delete(serverName string) (bool, error) {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return false, err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
if err != nil {
return false, err
}
return store.store.Delete(key)
defer unlock()
state, err := store.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[serverName]; !ok {
return false, nil
}
delete(state.Tokens, serverName)
if err := store.writeState(state); err != nil {
return false, err
}
return true, nil
}
// Status returns a redaction-safe summary of every stored MCP token, sorted by
// Status returns a redaction-safe summary of every stored token, sorted by
// server name. It never includes the token material.
func (store *TokenStore) Status() ([]TokenStatus, error) {
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return nil, err
}
out := make([]TokenStatus, 0, len(statuses))
for _, s := range statuses {
out = append(out, TokenStatus{
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
HasToken: s.HasToken,
HasRefreshToken: s.HasRefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
Expired: s.Expired,
})
names := make([]string, 0, len(state.Tokens))
for name := range state.Tokens {
names = append(names, name)
}
return out, nil
sort.Strings(names)
now := store.now()
statuses := make([]TokenStatus, 0, len(names))
for _, name := range names {
token := state.Tokens[name]
status := TokenStatus{
ServerName: name,
HasToken: strings.TrimSpace(token.AccessToken) != "",
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
TokenType: token.TokenType,
Scopes: token.Scopes,
ExpiresAt: token.ExpiresAt,
}
if !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now) {
status.Expired = true
}
statuses = append(statuses, status)
}
return statuses, nil
}
// migrateLegacy imports tokens from a legacy mcp-oauth-tokens.json into the
// unified store (under "mcp:" keys), then renames the legacy file to a
// ".migrated" backup. It is non-destructive and idempotent: a newer unified
// entry is never overwritten, a missing/unreadable/foreign-schema legacy file is
// left untouched, and the rename ensures it is imported at most once.
func (store *TokenStore) migrateLegacy(legacyPath string) error {
legacyPath = filepath.Clean(legacyPath)
// FilePath() is an absolute path; resolve a relative LegacyPath so the
// same-file guard can't be bypassed by a relative spelling of the same file.
if !filepath.IsAbs(legacyPath) {
abs, err := filepath.Abs(legacyPath)
if err != nil {
return err
}
legacyPath = filepath.Clean(abs)
}
if legacyPath == store.store.FilePath() {
return nil // legacy and unified resolve to the same file; nothing to migrate
}
data, err := os.ReadFile(legacyPath)
func (store *TokenStore) readState() (tokenFile, error) {
data, err := os.ReadFile(store.filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
return emptyTokenFile(), nil
}
return tokenFile{}, err
}
var state tokenFile
if err := json.Unmarshal(data, &state); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
if state.SchemaVersion != tokenStoreSchemaVersion {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: unsupported schemaVersion", store.filePath)
}
if state.Tokens == nil {
state.Tokens = map[string]StoredToken{}
}
for serverName := range state.Tokens {
if err := ValidateServerName(serverName); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
}
return state, nil
}
func (store *TokenStore) writeState(state tokenFile) error {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return err
}
var legacy tokenFile
if err := json.Unmarshal(data, &legacy); err != nil {
return nil // unreadable legacy file: leave it in place, don't block startup
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if legacy.SchemaVersion != tokenStoreSchemaVersion {
return nil // unknown legacy schema: leave it untouched
tempPath := fmt.Sprintf("%s.tmp-%d-%d", store.filePath, os.Getpid(), store.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
}
for serverName, token := range legacy.Tokens {
key, err := mcpKey(serverName)
if err := os.Rename(tempPath, store.filePath); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func (store *TokenStore) lockStateFile() (func(), error) {
lockPath := store.filePath + ".lockfile"
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return nil, err
}
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
deadline := time.Now().Add(tokenStoreLockTimeout)
for {
locked, err := tryLockPermissionFile(file)
if err != nil {
continue // a name that cannot form a valid unified key is skipped
_ = file.Close()
return nil, err
}
if _, ok, loadErr := store.store.Load(key); loadErr == nil && ok {
continue // a unified entry already exists; never overwrite
if locked {
return func() {
_ = unlockPermissionFile(file)
_ = file.Close()
}, nil
}
if err := store.store.Save(key, storedToOAuth(token)); err != nil {
return err
if time.Now().After(deadline) {
_ = file.Close()
return nil, fmt.Errorf("timed out waiting for MCP OAuth token lock at %s", lockPath)
}
time.Sleep(tokenStoreLockRetry)
}
return os.Rename(legacyPath, legacyPath+".migrated")
}
// mcpKey builds and validates the unified store key for an MCP server token.
func mcpKey(serverName string) (string, error) {
if err := ValidateServerName(serverName); err != nil {
return "", err
}
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
// inverse of tokenToStored). MCP never sets the oauth Account field.
func storedToOAuth(s StoredToken) oauth.Token {
return oauth.Token{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
func emptyTokenFile() tokenFile {
return tokenFile{
SchemaVersion: tokenStoreSchemaVersion,
Tokens: map[string]StoredToken{},
}
}
-95
View File
@@ -158,101 +158,6 @@ func TestTokenStoreStatusReportsPresenceWithoutToken(t *testing.T) {
}
}
func TestTokenStoreMigratesLegacyFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
legacyData := `{"schemaVersion":1,"tokens":{"demo":{"access_token":"a","refresh_token":"r","token_type":"Bearer"}}}`
if err := os.WriteFile(legacy, []byte(legacyData), 0o600); err != nil {
t.Fatal(err)
}
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore: %v", err)
}
tok, ok, err := store.Load("demo")
if err != nil || !ok {
t.Fatalf("migrated token not loadable: ok=%v err=%v", ok, err)
}
if tok.AccessToken != "a" || tok.RefreshToken != "r" {
t.Fatalf("migrated token = %#v", tok)
}
// The unified file keys the token under the mcp: namespace.
raw, err := os.ReadFile(unified)
if err != nil {
t.Fatalf("read unified: %v", err)
}
if !contains(string(raw), "mcp:demo") {
t.Fatalf("unified file should key under mcp: namespace:\n%s", raw)
}
// The legacy file is renamed to a .migrated backup (non-destructive, one-time).
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("legacy file should be renamed away; stat err = %v", err)
}
if _, err := os.Stat(legacy + ".migrated"); err != nil {
t.Fatalf("legacy backup missing: %v", err)
}
// Idempotent: a second construction (legacy now absent) keeps the token.
store2, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore#2: %v", err)
}
if _, ok, _ := store2.Load("demo"); !ok {
t.Fatal("token lost after second construction")
}
}
func TestTokenStoreMigrationPreservesNewerUnified(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
if err := os.WriteFile(legacy, []byte(`{"schemaVersion":1,"tokens":{"demo":{"access_token":"OLD"}}}`), 0o600); err != nil {
t.Fatal(err)
}
// Pre-seed the unified store with a newer token (no migration: FilePath set, no LegacyPath).
pre, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := pre.Save("demo", StoredToken{AccessToken: "NEW"}); err != nil {
t.Fatal(err)
}
// Migrating must not overwrite the newer unified entry.
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatal(err)
}
tok, _, _ := store.Load("demo")
if tok.AccessToken != "NEW" {
t.Fatalf("migration overwrote a newer token: %q", tok.AccessToken)
}
}
func TestTokenStoreNamespacedFromProvider(t *testing.T) {
// An MCP token and a provider login of the same name coexist in one file.
dir := t.TempDir()
unified := filepath.Join(dir, "oauth-tokens.json")
mcpStore, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := mcpStore.Save("shared", StoredToken{AccessToken: "mcp-token"}); err != nil {
t.Fatal(err)
}
statuses, err := mcpStore.Status()
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].ServerName != "shared" {
t.Fatalf("status = %#v, want one entry for 'shared'", statuses)
}
}
func TestResolveTokenStorePathUsesXDG(t *testing.T) {
// Use a real temp dir so the base is absolute on every OS (a literal
// "/tmp/..." isn't absolute on Windows, where ResolveTokenStorePath would
-147
View File
@@ -1,147 +0,0 @@
package oauth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// secretBytes is the AES-256 key length kept in the per-user secret file.
const secretBytes = 32
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
// per-user random secret persisted (0600) beside the token file. The on-disk
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
// detection, so a corrupted/forged file fails closed on open. This is the
// opt-in "encrypted-file" storage backend; the default backend writes the
// 0600 plaintext JSON unchanged.
type aesGCMCrypter struct {
secretPath string
}
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
return &aesGCMCrypter{secretPath: secretPath}
}
// aead loads (or, when create is set, generates) the secret and returns the GCM
// AEAD. open passes create=false so a missing secret is a hard error rather than
// silently minting a new key that could never decrypt the existing file.
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
secret, err := loadOrCreateSecret(c.secretPath, create)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(secret)
if err != nil {
return nil, fmt.Errorf("oauth: build cipher: %w", err)
}
return cipher.NewGCM(block)
}
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
// on first use.
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
gcm, err := c.aead(true)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
// a short blob, or a failed authentication tag (tampering / wrong key).
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
gcm, err := c.aead(false)
if err != nil {
return nil, err
}
if len(blob) < gcm.NonceSize() {
return nil, errors.New("oauth: encrypted token file is too short")
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
}
return plaintext, nil
}
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
// the file is absent, it generates a random secret and creates the file
// exclusively (0600). A wrong-sized existing secret fails closed (corruption).
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
if data, err := readSecretFile(path); err == nil {
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if !create {
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
}
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
// Create exclusively: an os.Rename publish would clobber on POSIX, so two
// concurrent first-run processes could each generate a secret and the loser
// would silently orphan the tokens it encrypts. O_EXCL lets exactly one
// process win; everyone else adopts the winner's on-disk secret.
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if errors.Is(err, os.ErrExist) {
// The winner may not have finished writing yet, so a read here can see a
// short/absent file. Retry briefly so concurrent first-run invocations
// converge on the winner's secret instead of failing transiently.
var lastErr error
for attempt := 0; attempt < 50; attempt++ {
secret, rerr := readSecretFile(path)
if rerr == nil {
return secret, nil
}
lastErr = rerr
time.Sleep(2 * time.Millisecond)
}
return nil, lastErr
}
return nil, fmt.Errorf("oauth: create token secret: %w", err)
}
if _, werr := f.Write(secret); werr != nil {
_ = f.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
}
return secret, nil
}
// readSecretFile reads and validates the secret at path, returning a wrapped
// os.ErrNotExist when it is absent so callers can branch on creation.
func readSecretFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, err
}
return nil, fmt.Errorf("oauth: read token secret: %w", err)
}
if len(data) != secretBytes {
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
}
return data, nil
}
-204
View File
@@ -1,204 +0,0 @@
package oauth
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
const n = 16
secrets := make([][]byte, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
secrets[i], errs[i] = loadOrCreateSecret(path, true)
}(i)
}
wg.Wait()
// Exactly one creator wins; every racer must converge on the same on-disk
// secret rather than orphaning its own (the O_EXCL + bounded-retry path).
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: %v", i, errs[i])
}
if !bytes.Equal(secrets[i], secrets[0]) {
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
}
}
}
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
c := newAESGCMCrypter(secretPath)
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
blob, err := c.seal(plaintext)
if err != nil {
t.Fatalf("seal: %v", err)
}
if bytes.Contains(blob, plaintext) {
t.Fatal("sealed blob must not contain the plaintext")
}
got, err := c.open(blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("round-trip mismatch: %q", got)
}
// Tamper: flipping any byte breaks the GCM tag.
tampered := append([]byte(nil), blob...)
tampered[len(tampered)-1] ^= 0xff
if _, err := c.open(tampered); err == nil {
t.Fatal("open must reject a tampered blob (GCM auth)")
}
}
func TestLoadOrCreateSecret(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
// Missing + create=false => fail closed (can't decrypt without the secret).
if _, err := loadOrCreateSecret(path, false); err == nil {
t.Fatal("missing secret with create=false must error")
}
secret, err := loadOrCreateSecret(path, true)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
// Stable across reads.
again, err := loadOrCreateSecret(path, false)
if err != nil || !bytes.Equal(again, secret) {
t.Fatalf("secret not stable: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat secret: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("secret file mode = %o, want 600", perm)
}
}
// Wrong-sized secret fails closed.
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatalf("corrupt secret: %v", err)
}
if _, err := loadOrCreateSecret(path, true); err == nil {
t.Fatal("wrong-sized secret must error")
}
}
func newEncryptedStore(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore(encrypted): %v", err)
}
return s, path
}
// The unified Storage="encrypted-file" selector must encrypt at rest, the same as
// the legacy Encrypted:true alias (the file/keyring/encrypted-file merge).
func TestNewStoreEncryptedFileStorageSelector(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
if err != nil {
t.Fatalf("NewStore(encrypted-file): %v", err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "super-secret-access"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("Storage=encrypted-file did not encrypt at rest:\n%s", raw)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
}
func TestEncryptedStoreRoundTripAndCiphertextOnDisk(t *testing.T) {
s, path := newEncryptedStore(t)
tok := Token{AccessToken: "super-secret-access", RefreshToken: "super-secret-refresh", Account: "me@x"}
if err := s.Save(ProviderKey("demo"), tok); err != nil {
t.Fatalf("Save: %v", err)
}
// On-disk file must be ciphertext: not valid JSON, no plaintext token.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("token file is not encrypted at rest:\n%s", raw)
}
// The secret file exists beside it.
if _, err := os.Stat(path + ".secret"); err != nil {
t.Fatalf("secret file missing: %v", err)
}
// A fresh Store (same path) decrypts and reads it back.
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore 2: %v", err)
}
got, ok, err := s2.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" || got.Account != "me@x" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
// Delete + Status work through the encrypted backend too.
statuses, err := s2.Status(KeyPrefixProvider)
if err != nil || len(statuses) != 1 {
t.Fatalf("Status = %+v err=%v", statuses, err)
}
if removed, err := s2.Delete(ProviderKey("demo")); err != nil || !removed {
t.Fatalf("Delete = %v %v", removed, err)
}
}
func TestEncryptedStoreTamperFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if len(raw) == 0 {
t.Fatal("encrypted token file is empty")
}
raw[len(raw)-1] ^= 0xff
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("rewrite: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("a tampered encrypted store must fail closed")
}
}
func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
if err := os.Remove(path + ".secret"); err != nil {
t.Fatalf("remove secret: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("missing secret must fail closed, not return empty")
}
}
+10 -98
View File
@@ -2,7 +2,6 @@ package oauth
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -10,11 +9,6 @@ import (
"time"
)
// ErrNoToken reports that no token is stored for a key. Callers (e.g. a request
// path that prefers OAuth but can fall back to an API key) match it with
// errors.Is to distinguish "not logged in" from a real refresh failure.
var ErrNoToken = errors.New("oauth: no stored token")
// defaultRefreshBuffer refreshes a token this long before its hard expiry.
const defaultRefreshBuffer = 60 * time.Second
@@ -132,62 +126,6 @@ func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error)
return m.statusFor(key)
}
// PrepareDeviceLogin resolves the provider config and requests an RFC 8628
// device code, returning the user-facing DeviceAuth (verification URI + user
// code) and the resolved config. It is split from the token poll
// (CompleteDeviceLogin) so a UI can display the code to the user while waiting
// for authorization. The CLI's monolithic Login(Device:true) is unaffected.
func (m *Manager) PrepareDeviceLogin(ctx context.Context, opts LoginOptions) (DeviceAuth, Config, error) {
cfg, _, err := m.registry.ResolveConfig(opts.Provider, m.env)
if err != nil {
return DeviceAuth{}, Config{}, err
}
if len(opts.ExtraScopes) > 0 {
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
}
// Self-bound the network prepare (endpoint discovery + device-code request) the
// same way Login does, so a caller that hands in an unbounded context still gets
// the timeout guarantee instead of a hang.
timeout := opts.Timeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
prepCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cfg, err = m.resolveEndpoints(prepCtx, cfg)
if err != nil {
return DeviceAuth{}, Config{}, err
}
auth, err := RequestDeviceCode(prepCtx, m.client, cfg, m.now)
if err != nil {
return DeviceAuth{}, Config{}, err
}
return auth, cfg, nil
}
// CompleteDeviceLogin polls for the token authorized via PrepareDeviceLogin and
// stores it under "provider:<name>", returning a redaction-safe status. Pass the
// cfg and auth returned by PrepareDeviceLogin.
func (m *Manager) CompleteDeviceLogin(ctx context.Context, provider string, cfg Config, auth DeviceAuth) (Status, error) {
// Bound the poll by the device code's own expiry so an unbounded caller context
// cannot leave an in-flight request hanging past the code's lifetime. PollDeviceToken
// re-checks ExpiresAt between polls; the deadline also caps each individual request.
if !auth.ExpiresAt.IsZero() {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, auth.ExpiresAt)
defer cancel()
}
token, err := PollDeviceToken(ctx, m.client, cfg, auth, m.now)
if err != nil {
return Status{}, err
}
key := ProviderKey(provider)
if err := m.store.Save(key, token); err != nil {
return Status{}, err
}
return m.statusFor(key)
}
// resolveEndpoints fills missing authorize/token/device endpoints from issuer
// discovery (RFC 8414 then OIDC), leaving any explicitly-pinned endpoint intact.
func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, error) {
@@ -276,31 +214,20 @@ func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
// stored token is expired or within the refresh buffer. Mirrors
// checkAndRefreshOAuthTokenIfNeeded.
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
token, err := m.loadToken(key)
token, cfg, err := m.loadForKey(key)
if err != nil {
return "", err
}
// A still-valid token is returned without resolving the provider config, so a
// readable login never depends on config that is only needed to refresh (e.g.
// an opt-in preset client_id). Only an actual refresh resolves endpoints.
if !token.NeedsRefresh(m.now(), m.buffer) {
return token.AccessToken, nil
}
cfg, err := m.resolveConfigForKey(ctx, key)
if err != nil {
return "", err
}
return m.refreshAndSave(ctx, key, cfg, token)
}
// Handle401 forces a refresh after an upstream 401, returning the new access
// token. Mirrors handleOAuth401Error.
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
token, err := m.loadToken(key)
if err != nil {
return "", err
}
cfg, err := m.resolveConfigForKey(ctx, key)
token, cfg, err := m.loadForKey(key)
if err != nil {
return "", err
}
@@ -318,42 +245,27 @@ func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, cu
return refreshed.AccessToken, nil
}
// loadToken loads a stored token for key without resolving any provider config,
// so reading a still-valid login never depends on refresh-only configuration.
func (m *Manager) loadToken(key string) (Token, error) {
// loadForKey loads a stored token and resolves the provider config for its key.
func (m *Manager) loadForKey(key string) (Token, Config, error) {
if err := ValidateKey(key); err != nil {
return Token{}, err
return Token{}, Config{}, err
}
token, ok, err := m.store.Load(key)
if err != nil {
return Token{}, err
return Token{}, Config{}, err
}
if !ok {
return Token{}, fmt.Errorf("%w for %q", ErrNoToken, key)
return Token{}, Config{}, fmt.Errorf("oauth: no stored token for %q", key)
}
return token, nil
}
// resolveConfigForKey resolves the provider OAuth config for a provider-token key.
// It is only needed to refresh a token (the endpoints + client identity), not to
// read one.
func (m *Manager) resolveConfigForKey(ctx context.Context, key string) (Config, error) {
name := strings.TrimPrefix(key, KeyPrefixProvider)
if name == key {
return Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
return Token{}, Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
}
cfg, _, err := m.registry.ResolveConfig(name, m.env)
if err != nil {
return Config{}, err
return Token{}, Config{}, err
}
// Fill any missing token/authorize/device endpoints from issuer discovery so a
// provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL can still refresh
// (refreshAndSave requires the token endpoint).
cfg, err = m.resolveEndpoints(ctx, cfg)
if err != nil {
return Config{}, err
}
return cfg, nil
return token, cfg, nil
}
// Logout removes a provider's stored token, reporting whether one was present.
-70
View File
@@ -106,76 +106,6 @@ func TestManagerLoginDevice(t *testing.T) {
}
}
func TestManagerPrepareAndCompleteDeviceLogin(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"two-phase-at","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
"ZERO_OAUTH_DEMO2_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO2_TOKEN_URL": fp.server.URL + "/token",
"ZERO_OAUTH_DEMO2_DEVICE_URL": fp.server.URL + "/device",
}
m := managerFor(t, env, nil)
// Phase 1: request the device code (what the UI displays to the user).
auth, cfg, err := m.PrepareDeviceLogin(context.Background(), LoginOptions{Provider: "demo2"})
if err != nil {
t.Fatalf("PrepareDeviceLogin: %v", err)
}
if auth.UserCode != "U-1" || auth.VerificationURI == "" {
t.Fatalf("device auth missing user code/uri: %+v", auth)
}
// Phase 2: poll for the token and persist it.
status, err := m.CompleteDeviceLogin(context.Background(), "demo2", cfg, auth)
if err != nil {
t.Fatalf("CompleteDeviceLogin: %v", err)
}
if !status.HasToken || status.Key != ProviderKey("demo2") {
t.Fatalf("status = %+v", status)
}
access, err := m.GetFresh(context.Background(), ProviderKey("demo2"))
if err != nil {
t.Fatalf("GetFresh: %v", err)
}
if access != "two-phase-at" {
t.Fatalf("access = %q, want two-phase-at", access)
}
}
// A provider configured with only an issuer URL (no TOKEN_URL) must still be
// refreshable: GetFresh resolves the token endpoint via discovery before
// refreshing. Guards resolveConfigForKey calling resolveEndpoints.
func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
var tokenHits int32
mux := http.NewServeMux()
server := httptest.NewServer(mux)
defer server.Close()
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"`+server.URL+`/authorize"}`)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&tokenHits, 1)
_, _ = io.WriteString(w, `{"access_token":"refreshed-at","token_type":"Bearer","expires_in":3600}`)
})
env := map[string]string{
"ZERO_OAUTH_ISSUERONLY_CLIENT_ID": "client",
"ZERO_OAUTH_ISSUERONLY_ISSUER_URL": server.URL, // no TOKEN_URL → discovered
}
m := managerFor(t, env, nil)
if err := m.store.Save(ProviderKey("issueronly"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
t.Fatalf("seed: %v", err)
}
got, err := m.GetFresh(context.Background(), ProviderKey("issueronly"))
if err != nil {
t.Fatalf("GetFresh (issuer-only) must discover the token endpoint and refresh: %v", err)
}
if got != "refreshed-at" {
t.Fatalf("access = %q, want refreshed-at", got)
}
if atomic.LoadInt32(&tokenHits) == 0 {
t.Fatal("token endpoint never hit — discovery/refresh did not run")
}
}
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
env := map[string]string{
+3 -5
View File
@@ -14,11 +14,9 @@
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
// - Token files are 0600, base-confined, and file-locked.
//
// This package holds NO vendor secrets: provider client IDs and endpoints come
// from config/env by default, so no third-party OAuth client identity is used
// unless the operator opts in. A small set of built-in presets (e.g. xAI's public
// client) exists for convenience but is OFF by default and only consulted when
// ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.
// This package holds NO vendor secrets: provider client IDs and any overridable
// endpoints come from config/env, so no third-party OAuth client identity is
// baked into the binary.
package oauth
import (
-74
View File
@@ -1,74 +0,0 @@
package oauth
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
// env vars (env wins). Only providers whose OAuth flow is verified to yield a
// credential usable for model calls are listed here.
type providerPreset struct {
ClientID string
ClientSecret string
AuthorizationEndpoint string
TokenEndpoint string
DeviceAuthorizationEndpoint string
IssuerURL string
Scopes []string
Flow Flow
}
// builtinOAuthPresets maps a provider name to its default OAuth config.
//
// These presets are OFF by default and only consulted when the operator opts in
// with ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed). A preset carries a
// third-party OAuth client identity, and the engine keeps such identities out of
// the default credential path (see the package doc) — opting in is an explicit
// acknowledgement that the binary's preset client_id will be used when no
// ZERO_OAUTH_<NAME>_* override is set.
//
// xAI (Grok): the client_id is a PUBLIC client (no secret) used by several Grok
// CLIs; its access token is accepted directly as a bearer on api.x.ai/v1 (an
// OpenAI-compatible endpoint), so no header/identity spoofing is involved.
// CAVEATS: it is NOT formally documented by xAI as a public developer API and may
// change without notice (override via ZERO_OAUTH_XAI_*), and using it requires a
// SuperGrok / X Premium+ subscription. Pay-as-you-go users should use a console
// API key instead.
var builtinOAuthPresets = map[string]providerPreset{
"xai": {
ClientID: "b1a00492-073a-47ea-816f-4c329264a828",
AuthorizationEndpoint: "https://auth.x.ai/oauth2/authorize",
TokenEndpoint: "https://auth.x.ai/oauth2/token",
DeviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
IssuerURL: "https://auth.x.ai",
Scopes: []string{"openid", "profile", "email", "offline_access", "grok-cli:access", "api:access"},
Flow: FlowLoopback,
},
}
// lookupOAuthPreset returns the baked-in preset for a provider name (if any).
func lookupOAuthPreset(name string) (providerPreset, bool) {
preset, ok := builtinOAuthPresets[strings.ToLower(strings.TrimSpace(name))]
return preset, ok
}
// presetsAllowed reports whether baked-in OAuth presets may supply defaults. They
// are OFF unless the operator opts in with ZERO_OAUTH_ALLOW_PRESETS set to a
// truthy value, keeping any third-party OAuth client identity out of the default
// credential path (a preset client_id is only ever used after explicit opt-in).
func presetsAllowed(env map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(envValue(env, "ZERO_OAUTH_ALLOW_PRESETS"))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// scopesOrPreset returns the env scopes (space-separated) when set, else the
// preset's scopes.
func scopesOrPreset(envScopes string, preset []string) []string {
if fields := strings.Fields(envScopes); len(fields) > 0 {
return fields
}
// Copy so a caller appending to cfg.Scopes can't mutate the shared preset slice.
return append([]string(nil), preset...)
}
-95
View File
@@ -1,95 +0,0 @@
package oauth
import (
"strings"
"testing"
)
func TestScopesOrPresetReturnsACopy(t *testing.T) {
preset := []string{"openid", "profile"}
got := scopesOrPreset("", preset)
if len(got) != 2 || got[0] != "openid" {
t.Fatalf("got %v, want the preset scopes", got)
}
// Mutating the result must not bleed into the shared preset slice.
got[0] = "MUTATED"
if preset[0] != "openid" {
t.Fatalf("scopesOrPreset aliased the shared preset slice: %v", preset)
}
}
func TestResolveConfigUsesXAIPreset(t *testing.T) {
r := NewRegistry()
// Presets are opt-in; with ZERO_OAUTH_ALLOW_PRESETS set and no other env vars
// the preset supplies everything.
cfg, flow, err := r.ResolveConfig("xai", map[string]string{"ZERO_OAUTH_ALLOW_PRESETS": "1"})
if err != nil {
t.Fatalf("ResolveConfig(xai): %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
if cfg.AuthorizationEndpoint != "https://auth.x.ai/oauth2/authorize" {
t.Fatalf("authorize = %q", cfg.AuthorizationEndpoint)
}
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("token = %q", cfg.TokenEndpoint)
}
if cfg.DeviceAuthorizationEndpoint != "https://auth.x.ai/oauth2/device/code" {
t.Fatalf("device = %q", cfg.DeviceAuthorizationEndpoint)
}
if flow != FlowLoopback {
t.Fatalf("flow = %q, want loopback", flow)
}
if len(cfg.Scopes) == 0 {
t.Fatal("preset scopes should be populated")
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_ALLOW_PRESETS": "1",
"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id",
"ZERO_OAUTH_XAI_SCOPES": "alpha beta",
"ZERO_OAUTH_XAI_FLOW": "device",
}
cfg, flow, err := r.ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig(xai, env): %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("env should override client_id, got %q", cfg.ClientID)
}
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "alpha" {
t.Fatalf("env should override scopes, got %v", cfg.Scopes)
}
if flow != FlowDevice {
t.Fatalf("env should override flow, got %q", flow)
}
// A field not overridden still comes from the preset.
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("non-overridden token endpoint = %q", cfg.TokenEndpoint)
}
}
func TestResolveConfigNoPresetStillRequiresEnv(t *testing.T) {
r := NewRegistry()
if _, _, err := r.ResolveConfig("acme-no-preset", map[string]string{}); err == nil {
t.Fatal("a provider with neither preset nor env config should error")
}
}
// Without the opt-in flag the xAI preset must stay inert: no third-party client
// identity is baked into the default credential path, and the error points the
// user at the opt-in.
func TestResolveConfigPresetInertWithoutOptIn(t *testing.T) {
r := NewRegistry()
_, _, err := r.ResolveConfig("xai", map[string]string{})
if err == nil {
t.Fatal("xai must not resolve from the preset unless ZERO_OAUTH_ALLOW_PRESETS is set")
}
if !strings.Contains(err.Error(), "ZERO_OAUTH_ALLOW_PRESETS") {
t.Fatalf("error should point at the opt-in, got: %v", err)
}
}
+13 -30
View File
@@ -28,11 +28,10 @@ func ValidateProviderName(name string) error {
return nil
}
// Registry resolves a provider's Config from env/config. By default every provider
// is defined entirely by the operator via ZERO_OAUTH_<NAME>_* variables, so no
// third-party OAuth client identity is used. A small set of built-in presets
// exists for convenience but stays inert unless the operator opts in with
// ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed); env always overrides a preset.
// Registry resolves a provider's Config from env/config. It ships NO built-in
// providers, endpoints, or client identities: every provider is defined entirely
// by the operator via ZERO_OAUTH_<NAME>_* variables, so no third-party brand or
// OAuth client identity is baked into the binary.
type Registry struct{}
// NewRegistry returns the (stateless) env-driven registry.
@@ -62,37 +61,21 @@ func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Fl
if err := ValidateProviderName(name); err != nil {
return Config{}, "", err
}
// A baked-in preset (if any) supplies defaults, but ONLY when the operator opts
// in with ZERO_OAUTH_ALLOW_PRESETS — otherwise no third-party client identity is
// used and every field must come from a ZERO_OAUTH_<NAME>_* env var (env wins).
var preset providerPreset
if presetsAllowed(env) {
preset, _ = lookupOAuthPreset(name)
}
cfg := Config{
ClientID: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))), preset.ClientID),
ClientSecret: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))), preset.ClientSecret),
AuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))), preset.AuthorizationEndpoint),
TokenEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))), preset.TokenEndpoint),
DeviceAuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))), preset.DeviceAuthorizationEndpoint),
IssuerURL: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))), preset.IssuerURL),
Scopes: scopesOrPreset(envValue(env, envKey(name, "SCOPES")), preset.Scopes),
ClientID: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))),
ClientSecret: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))),
AuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))),
TokenEndpoint: strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))),
DeviceAuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))),
IssuerURL: strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))),
Scopes: strings.Fields(envValue(env, envKey(name, "SCOPES"))),
}
if cfg.ClientID == "" {
hint := ""
if _, ok := lookupOAuthPreset(name); ok {
hint = " (or set ZERO_OAUTH_ALLOW_PRESETS=1 to use the built-in preset)"
}
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)%s", name, envKey(name, "CLIENT_ID"), hint)
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)", name, envKey(name, "CLIENT_ID"))
}
var flow Flow
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
case "":
flow = preset.Flow
if flow == "" {
flow = FlowLoopback
}
case string(FlowLoopback):
case "", string(FlowLoopback):
flow = FlowLoopback
case string(FlowDevice):
flow = FlowDevice
+1 -1
View File
@@ -50,7 +50,7 @@ func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, don
defer close(done)
loadErrors := 0
for {
token, err := m.loadToken(key)
token, _, err := m.loadForKey(key)
if err != nil {
// A load error may be transient (a concurrent store write, a brief I/O
// hiccup): back off and retry rather than permanently stopping proactive
+56 -237
View File
@@ -1,20 +1,16 @@
package oauth
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/keyring"
)
const (
@@ -58,41 +54,14 @@ type StoreOptions struct {
FilePath string
Env map[string]string
Now func() time.Time
// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
Storage string
// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
// rest). Ignored when Storage is set.
Encrypted bool
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
// Injected by tests to avoid touching a real keychain.
Keyring KeyringClient
}
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
// satisfies it; tests inject a fake.
type KeyringClient interface {
Get(service, account string) (string, bool, error)
Set(service, account, secret string) error
Delete(service, account string) (bool, error)
}
// Keyring storage stores the whole token blob under one fixed entry.
const (
keyringService = "zero"
keyringAccount = "oauth-tokens"
)
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
// written atomically through a pluggable backend (a 0600 file guarded by a
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
// is AES-256-GCM ciphertext at rest.
// Store persists OAuth tokens (provider + MCP namespaces) in a 0600 JSON file,
// guarded by a cross-process lock and written atomically.
type Store struct {
blob blobStore
crypter *aesGCMCrypter // nil => plaintext blob
now func() time.Time
mu sync.Mutex
filePath string
now func() time.Time
mu sync.Mutex
}
type storeFile struct {
@@ -130,80 +99,29 @@ func ResolveStorePath(env map[string]string) (string, error) {
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
}
// NewStore builds a token store with the configured backend (file by default,
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
// NewStore builds a file-backed token store.
func NewStore(options StoreOptions) (*Store, error) {
now := options.Now
if now == nil {
now = time.Now
}
storage := strings.TrimSpace(options.Storage)
if storage == "" {
storage = strings.TrimSpace(envValue(options.Env, "ZERO_OAUTH_STORAGE"))
}
if storage == "" && options.Encrypted {
storage = "encrypted-file" // legacy alias
}
switch storage {
case "", "file":
path, err := resolveStoreFilePath(options)
if err != nil {
return nil, err
}
return &Store{blob: fileBlob{path: path}, now: now}, nil
case "encrypted-file":
path, err := resolveStoreFilePath(options)
if err != nil {
return nil, err
}
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
// a sibling ".secret" file (see encrypt.go).
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
case "keyring":
kr := options.Keyring
if kr == nil {
osKeyring := keyring.New()
if !osKeyring.Available() {
return nil, fmt.Errorf("oauth: keyring storage requested but not available on %s; use file storage", runtime.GOOS)
}
kr = osKeyring
}
// Serialize the keyring's read-modify-write across processes with a lock
// file beside where the file backend would live. Best-effort: if no config
// location resolves, fall back to in-process serialization only.
lockPath := ""
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
}
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
default:
return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage)
}
}
// resolveStoreFilePath resolves the absolute file path for the file backend.
func resolveStoreFilePath(options StoreOptions) (string, error) {
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveStorePath(options.Env)
if err != nil {
return "", err
return nil, err
}
}
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return "", err
return nil, err
}
}
return filepath.Clean(filePath), nil
now := options.Now
if now == nil {
now = time.Now
}
return &Store{filePath: filepath.Clean(filePath), now: now}, nil
}
// FilePath returns the resolved token store location (a path for the file
// backend, or a "keyring:..." identifier for the keyring backend).
func (s *Store) FilePath() string { return s.blob.location() }
// Save persists a token under key, replacing any existing entry.
func (s *Store) Save(key string, token Token) error {
if err := ValidateKey(key); err != nil {
@@ -211,14 +129,17 @@ func (s *Store) Save(key string, token Token) error {
}
s.mu.Lock()
defer s.mu.Unlock()
return s.blob.withLock(s.now, func() error {
state, err := s.readState()
if err != nil {
return err
}
state.Tokens[key] = token
return s.writeState(state)
})
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return err
}
defer unlock()
state, err := s.readState()
if err != nil {
return err
}
state.Tokens[key] = token
return s.writeState(state)
}
// Load returns the token for key; the bool is false when none is stored.
@@ -243,20 +164,23 @@ func (s *Store) Delete(key string) (bool, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
var removed bool
err := s.blob.withLock(s.now, func() error {
state, err := s.readState()
if err != nil {
return err
}
if _, ok := state.Tokens[key]; !ok {
return nil
}
delete(state.Tokens, key)
removed = true
return s.writeState(state)
})
return removed, err
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return false, err
}
defer unlock()
state, err := s.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[key]; !ok {
return false, nil
}
delete(state.Tokens, key)
if err := s.writeState(state); err != nil {
return false, err
}
return true, nil
}
// Status returns redaction-safe summaries of every stored token, sorted by key.
@@ -294,159 +218,54 @@ func (s *Store) Status(prefix string) ([]Status, error) {
}
func (s *Store) readState() (storeFile, error) {
data, ok, err := s.blob.read()
data, err := os.ReadFile(s.filePath)
if err != nil {
return storeFile{}, err
}
if !ok {
return emptyStoreFile(), nil
}
if s.crypter != nil {
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
data, err = s.crypter.open(data)
if err != nil {
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
if errors.Is(err, os.ErrNotExist) {
return emptyStoreFile(), nil
}
return storeFile{}, err
}
var state storeFile
if err := json.Unmarshal(data, &state); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
}
if state.SchemaVersion != storeSchemaVersion {
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: unsupported schemaVersion", s.blob.location())
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: unsupported schemaVersion", s.filePath)
}
if state.Tokens == nil {
state.Tokens = map[string]Token{}
}
for key := range state.Tokens {
if err := ValidateKey(key); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
}
}
return state, nil
}
func (s *Store) writeState(state storeFile) error {
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
// Plaintext keeps the trailing newline for a tidy file; the encrypted backend
// writes opaque ciphertext instead.
payload := append(data, '\n')
if s.crypter != nil {
payload, err = s.crypter.seal(data)
if err != nil {
return err
}
}
return s.blob.write(payload)
}
func emptyStoreFile() storeFile {
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
}
// blobStore abstracts the persistence of the whole token blob behind the Store,
// so the same store logic backs either a 0600 file or the OS keyring.
type blobStore interface {
// read returns the stored blob; ok is false when nothing is stored yet.
read() (data []byte, ok bool, err error)
// write replaces the stored blob.
write(data []byte) error
// withLock runs fn under whatever cross-process exclusion the backend offers
// (a lock file for the file backend; none for the keyring, which is the
// authoritative store and is serialized within the process by Store.mu).
withLock(now func() time.Time, fn func() error) error
// location is a human-readable identifier for diagnostics/errors.
location() string
}
// fileBlob persists the blob as a 0600 JSON file, written atomically and guarded
// by a cross-process lock file. Behavior matches the original file store.
type fileBlob struct{ path string }
func (b fileBlob) read() ([]byte, bool, error) {
data, err := os.ReadFile(b.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
return nil, false, err
}
return data, true, nil
}
func (b fileBlob) write(data []byte) error {
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
tempPath := fmt.Sprintf("%s.tmp-%d-%d", s.filePath, os.Getpid(), s.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
return err
}
if err := os.Rename(tempPath, b.path); err != nil {
if err := os.Rename(tempPath, s.filePath); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
unlock, err := acquireFileLock(b.path+".lockfile", now)
if err != nil {
return err
}
defer unlock()
return fn()
func emptyStoreFile() storeFile {
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
}
func (b fileBlob) location() string { return b.path }
// keyringBlob persists the blob in the OS keyring as a single base64 entry
// (base64 keeps the multi-line JSON a single, control-character-free value).
type keyringBlob struct {
kr KeyringClient
service string
account string
// lockPath, when set, is a cross-process lock file serializing the keyring's
// read-modify-write so concurrent processes don't clobber each other's tokens.
lockPath string
}
func (b keyringBlob) read() ([]byte, bool, error) {
enc, ok, err := b.kr.Get(b.service, b.account)
if err != nil || !ok {
return nil, ok, err
}
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
if err != nil {
return nil, false, fmt.Errorf("oauth: decode keyring token blob: %w", err)
}
return data, true, nil
}
func (b keyringBlob) write(data []byte) error {
return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data))
}
// withLock serializes the keyring's read-modify-write. Store.mu covers the
// in-process case; lockPath (when set) adds cross-process exclusion so two
// processes can't both read the blob, modify, and write — dropping a token.
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
if b.lockPath == "" {
return fn()
}
unlock, err := acquireFileLock(b.lockPath, now)
if err != nil {
return err
}
defer unlock()
return fn()
}
func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account }
// FormatStatuses renders a human-readable status table without leaking token
// material.
func FormatStatuses(statuses []Status) string {
-113
View File
@@ -1,113 +0,0 @@
package oauth
import (
"strings"
"testing"
)
// fakeKR is an in-memory KeyringClient for exercising the keyring backend
// without touching a real OS keychain.
type fakeKR struct{ data map[string]string }
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}} }
func (f *fakeKR) Get(service, account string) (string, bool, error) {
v, ok := f.data[service+"/"+account]
return v, ok, nil
}
func (f *fakeKR) Set(service, account, secret string) error {
f.data[service+"/"+account] = secret
return nil
}
func (f *fakeKR) Delete(service, account string) (bool, error) {
key := service + "/" + account
_, ok := f.data[key]
delete(f.data, key)
return ok, nil
}
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
// Keep the cross-process keyring lock file inside a temp config dir.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatalf("NewStore(keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("FilePath = %q, want keyring identifier", s.FilePath())
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save: %v", err)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok {
t.Fatalf("Load: ok=%v err=%v", ok, err)
}
if got.AccessToken != "a" || got.RefreshToken != "r" {
t.Fatalf("Load = %#v", got)
}
// The blob is stored base64-encoded, so the raw JSON field names never appear.
raw := kr.data[keyringService+"/"+keyringAccount]
if raw == "" {
t.Fatal("nothing stored in keyring")
}
if strings.Contains(raw, "access_token") {
t.Fatalf("keyring blob is not encoded: %s", raw)
}
removed, err := s.Delete(ProviderKey("demo"))
if err != nil || !removed {
t.Fatalf("Delete: removed=%v err=%v", removed, err)
}
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
t.Fatal("token still present after delete")
}
}
func TestNewStoreStorageSelection(t *testing.T) {
// Unknown storage is rejected (fail closed).
if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil {
t.Fatal("unknown storage should error")
}
// ZERO_OAUTH_STORAGE selects the keyring (with an injected client).
s, err := NewStore(StoreOptions{
Env: map[string]string{"ZERO_OAUTH_STORAGE": "keyring"},
Keyring: newFakeKR(),
})
if err != nil {
t.Fatalf("NewStore(env keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("env did not select keyring backend: %q", s.FilePath())
}
// Default is the file backend.
fileStore, err := NewStore(StoreOptions{FilePath: t.TempDir() + "/oauth-tokens.json"})
if err != nil {
t.Fatalf("NewStore(file): %v", err)
}
if strings.HasPrefix(fileStore.FilePath(), "keyring:") {
t.Fatalf("default backend should be file, got %q", fileStore.FilePath())
}
}
func TestStoreKeyringStatus(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatal(err)
}
statuses, err := s.Status(KeyPrefixProvider)
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") || !statuses[0].HasToken {
t.Fatalf("status = %#v", statuses)
}
}
+2 -42
View File
@@ -47,17 +47,6 @@ type Descriptor struct {
Local bool
SupportedAPIFormats []APIFormat
Aliases []string
// OAuth reports that this provider offers an in-app OAuth login that yields a
// credential usable for model calls (browser PKCE and/or device code). Only
// set for providers where this actually works (not subscription-via-proxy).
OAuth bool
// OAuthMintsKey reports that the OAuth flow returns/mints a normal API key
// (e.g. OpenRouter) rather than a bearer token used directly.
OAuthMintsKey bool
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -88,7 +77,7 @@ var descriptors = []Descriptor{
openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"),
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}),
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
@@ -100,20 +89,13 @@ var descriptors = []Descriptor{
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),
transportDescriptor("vertex", "Vertex AI", TransportVertex, "https://aiplatform.googleapis.com", "gemini-2.5-pro", []string{"GOOGLE_APPLICATION_CREDENTIALS"}, []APIFormat{APIFormatVertexGenerateContent}, true),
oauthProvider(openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}), false, true),
openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}),
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("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
// the official client), so zero does not call it directly; instead point this
// at a local proxy that holds the OAuth session and exposes an OpenAI-compatible
// endpoint. Local (no API key — the proxy authenticates); override the base URL
// for your proxy's port. See docs/oauth-subscriptions.md.
localOpenAI("chatgpt-proxy", "ChatGPT (local OAuth proxy)", "http://localhost:10531/v1", "gpt-5", "chatgpt"),
openAICompat("custom-openai-compatible", "Custom OpenAI-compatible", "https://example.invalid/v1", "custom-model", []string{"OPENAI_API_KEY"}, "custom openai compatible"),
anthropicCompat("custom-anthropic-compatible", "Custom Anthropic-compatible", "https://example.invalid/anthropic", "custom-model", []string{"ANTHROPIC_API_KEY"}, "custom anthropic compatible"),
}
@@ -247,28 +229,6 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
descriptor.OAuth = true
descriptor.OAuthMintsKey = mintsKey
descriptor.OAuthDeviceFlow = deviceFlow
return descriptor
}
// OAuthProviders returns the catalog descriptors that support an in-app OAuth
// login, in catalog order. It is the single source of truth for the wizard's
// dedicated "Sign in with OAuth" provider list.
func OAuthProviders() []Descriptor {
out := []Descriptor{}
for _, descriptor := range descriptors {
if descriptor.OAuth {
out = append(out, cloneDescriptor(descriptor))
}
}
return out
}
func localOpenAI(id string, name string, baseURL string, model string, aliases ...string) Descriptor {
descriptor := openAICompat(id, name, baseURL, model, nil, aliases...)
descriptor.RequiresAuth = false
+1 -15
View File
@@ -33,7 +33,6 @@ var expectedCatalogIDs = []string{
"zai",
"gitlawb-opengateway",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
"custom-anthropic-compatible",
}
@@ -221,7 +220,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
@@ -278,19 +277,6 @@ func TestReturnedDescriptorsAreCopies(t *testing.T) {
}
}
func TestOAuthProviderClassification(t *testing.T) {
oauthIDs := descriptorIDs(OAuthProviders())
if want := []string{"openrouter", "xai"}; !reflect.DeepEqual(oauthIDs, want) {
t.Fatalf("OAuthProviders() = %#v, want %#v", oauthIDs, want)
}
if d, _ := Get("openrouter"); !d.OAuthMintsKey {
t.Fatal("openrouter should mint a key")
}
if d, _ := Get("xai"); !d.OAuthDeviceFlow {
t.Fatal("xai should advertise device-code flow")
}
}
func descriptorIDs(descriptors []Descriptor) []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
-60
View File
@@ -1,60 +0,0 @@
package providercatalog
import "testing"
func TestOAuthProviders(t *testing.T) {
providers := OAuthProviders()
if len(providers) != 2 {
t.Fatalf("OAuthProviders() = %d, want 2 (openrouter, xai)", len(providers))
}
byID := map[string]Descriptor{}
for _, d := range providers {
if !d.OAuth {
t.Fatalf("OAuthProviders() returned a non-OAuth descriptor %q", d.ID)
}
byID[d.ID] = d
}
or, ok := byID["openrouter"]
if !ok || !or.OAuthMintsKey || or.OAuthDeviceFlow {
t.Fatalf("openrouter oauth flags wrong: %+v", or)
}
xai, ok := byID["xai"]
if !ok || xai.OAuthMintsKey || !xai.OAuthDeviceFlow {
t.Fatalf("xai oauth flags wrong: %+v", xai)
}
}
func TestOAuthProvidersReturnsIndependentClones(t *testing.T) {
first := OAuthProviders()
mutated := 0
for i := range first {
for j := range first[i].AuthEnvVars {
first[i].AuthEnvVars[j] = "MUTATED"
mutated++
}
}
if mutated == 0 {
t.Fatal("expected at least one OAuth descriptor with AuthEnvVars to mutate")
}
// A fresh call must not observe the in-place mutation: like the other catalog
// accessors, OAuthProviders must hand back clones, not shared backing slices.
for _, d := range OAuthProviders() {
for _, env := range d.AuthEnvVars {
if env == "MUTATED" {
t.Fatalf("OAuthProviders() leaked a shared AuthEnvVars slice for %q", d.ID)
}
}
}
}
func TestNonOAuthProvidersNotFlagged(t *testing.T) {
for _, d := range All() {
switch d.ID {
case "openrouter", "xai":
continue
}
if d.OAuth {
t.Fatalf("provider %q should not be flagged OAuth-capable", d.ID)
}
}
}
-172
View File
@@ -1,172 +0,0 @@
// Package provideroauth implements provider-specific OAuth login flows that don't
// fit the generic env-driven engine in internal/oauth — notably OpenRouter's
// bespoke PKCE flow, which mints a normal API key rather than returning an OAuth
// bearer token. These flows are launched from the setup wizard / CLI so a user
// can "log in with the browser" instead of pasting a key.
package provideroauth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
const (
openRouterDefaultBaseURL = "https://openrouter.ai"
openRouterAuthPath = "/auth"
openRouterKeyExchangePath = "/api/v1/auth/keys"
openRouterMaxBody = 1 << 20
)
// OpenRouterOptions configures the OpenRouter login flow.
type OpenRouterOptions struct {
// BaseURL overrides https://openrouter.ai (for tests).
BaseURL string
// HTTPClient performs the key exchange; nil => a client with a sane timeout.
HTTPClient *http.Client
// OpenBrowser is invoked with the authorize URL. When nil the URL is only
// printed (to Out) for the user to open manually.
OpenBrowser func(authURL string) error
// Out receives the "open this URL" line; nil => the URL is not printed.
Out io.Writer
// Timeout bounds the whole interactive login; 0 => 5 minutes.
Timeout time.Duration
}
// OpenRouterLogin runs OpenRouter's loopback PKCE flow and returns a freshly
// minted OpenRouter API key. The flow needs no client_id: a local callback
// captures the authorization code, which is exchanged (with the PKCE verifier)
// at /api/v1/auth/keys for a key. PKCE (S256) binds the code to this client, so
// no separate CSRF state is required. The returned key is a normal credential
// the caller stores as the provider's API key.
func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error) {
base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
if base == "" {
base = openRouterDefaultBaseURL
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
pkce, err := oauth.NewPKCE()
if err != nil {
return "", err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", fmt.Errorf("provideroauth: start loopback listener: %w", err)
}
defer func() { _ = listener.Close() }()
callbackURL := fmt.Sprintf("http://%s/callback", listener.Addr().String())
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
if code := strings.TrimSpace(r.URL.Query().Get("code")); code != "" {
_, _ = io.WriteString(w, "OpenRouter authorization complete. You may close this window.")
select {
case codeCh <- code:
default:
}
return
}
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
select {
case errCh <- errors.New("provideroauth: callback missing authorization code"):
default:
}
})}
go func() { _ = server.Serve(listener) }()
defer func() {
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
defer cancelShutdown()
_ = server.Shutdown(shutdownCtx)
}()
authURL := base + openRouterAuthPath + "?" + url.Values{
"callback_url": {callbackURL},
"code_challenge": {pkce.Challenge},
"code_challenge_method": {oauth.MethodS256},
}.Encode()
if opts.Out != nil {
fmt.Fprintf(opts.Out, "Open this URL to authorize OpenRouter:\n %s\n", authURL)
}
if opts.OpenBrowser != nil {
if err := opts.OpenBrowser(authURL); err != nil {
return "", fmt.Errorf("provideroauth: open authorization URL: %w", err)
}
}
var code string
select {
case code = <-codeCh:
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", fmt.Errorf("provideroauth: timed out waiting for OpenRouter authorization: %w", ctx.Err())
}
return openRouterExchange(ctx, client, base, code, pkce.Verifier)
}
// openRouterExchange swaps the authorization code + PKCE verifier for an API key.
func openRouterExchange(ctx context.Context, client *http.Client, base, code, verifier string) (string, error) {
payload, err := json.Marshal(map[string]string{
"code": code,
"code_verifier": verifier,
"code_challenge_method": oauth.MethodS256,
})
if err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+openRouterKeyExchangePath, bytes.NewReader(payload))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("provideroauth: openrouter key exchange: %w", err)
}
defer func() { _ = response.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(response.Body, openRouterMaxBody))
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("provideroauth: openrouter key exchange returned HTTP %d", response.StatusCode)
}
var parsed struct {
Key string `json:"key"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("provideroauth: decode openrouter key response: %w", err)
}
key := strings.TrimSpace(parsed.Key)
if key == "" {
return "", errors.New("provideroauth: openrouter returned an empty key")
}
return key, nil
}
-131
View File
@@ -1,131 +0,0 @@
package provideroauth
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
// callback_url with the given code (or no code), mimicking OpenRouter's redirect.
// It also asserts the PKCE params are present on the authorize URL.
func simulateBrowser(t *testing.T, code string) func(string) error {
t.Helper()
return func(authURL string) error {
u, err := url.Parse(authURL)
if err != nil {
return err
}
q := u.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Fatalf("authorize URL missing PKCE: %s", authURL)
}
cb := q.Get("callback_url")
if cb == "" {
t.Fatalf("authorize URL missing callback_url: %s", authURL)
}
target := cb
if code != "" {
target = cb + "?code=" + url.QueryEscape(code)
}
resp, err := http.Get(target) //nolint:noctx // test loopback
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
}
func TestOpenRouterLoginMintsKey(t *testing.T) {
var gotCode, gotVerifier, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/auth/keys" || r.Method != http.MethodPost {
w.WriteHeader(http.StatusNotFound)
return
}
var body map[string]string
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body)
gotCode, gotVerifier, gotMethod = body["code"], body["code_verifier"], body["code_challenge_method"]
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
}))
defer srv.Close()
key, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "TESTCODE"),
})
if err != nil {
t.Fatalf("OpenRouterLogin: %v", err)
}
if key != "sk-or-mock-123" {
t.Fatalf("key = %q, want sk-or-mock-123", key)
}
if gotCode != "TESTCODE" || gotVerifier == "" || gotMethod != "S256" {
t.Fatalf("exchange body: code=%q verifier=%q method=%q", gotCode, gotVerifier, gotMethod)
}
}
func TestOpenRouterLoginMissingCodeErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, ""), // callback with no code
})
if err == nil {
t.Fatal("a callback without a code should error")
}
}
func TestOpenRouterLoginExchangeFailureErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden) // exchange rejected
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "C"),
})
if err == nil {
t.Fatal("a non-2xx exchange should error")
}
}
func TestOpenRouterLoginEmptyKeyErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"key":""}`)
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "C"),
})
if err == nil || !strings.Contains(err.Error(), "empty key") {
t.Fatalf("empty key should error, got %v", err)
}
}
func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: "http://127.0.0.1:1",
OpenBrowser: func(string) error { return errors.New("no browser") },
})
if err == nil {
t.Fatal("a browser-open failure should propagate")
}
}
+12 -20
View File
@@ -87,10 +87,6 @@ type Options struct {
CustomHeaders map[string]string
HTTPClient *http.Client
UserAgent string
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
// is preferred over APIKey; a nil resolver (or one that yields ok=false) uses
// the API key. See providerio.SendWithAuthRetry.
OAuthResolver providerio.TokenResolver
// StreamIdleTimeout aborts the stream if no data arrives for this long.
// Zero uses defaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
@@ -108,7 +104,6 @@ type Provider struct {
authScheme string
authHeaderValue string
customHeaders map[string]string
oauthResolver providerio.TokenResolver
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
@@ -147,7 +142,6 @@ func New(options Options) (*Provider, error) {
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
oauthResolver: options.OAuthResolver,
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
streamIdleTimeout: idleTimeout,
@@ -182,26 +176,24 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body,
providerio.AuthHeaders{
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body, func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
request.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
APIKey: provider.apiKey,
DefaultAuthHeader: "x-api-key",
AuthHeader: provider.authHeader,
AuthScheme: provider.authScheme,
AuthHeaderValue: provider.authHeaderValue,
CustomHeaders: provider.customHeaders,
},
provider.oauthResolver,
func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
request.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
}, 0)
})
}, 0)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
-7
View File
@@ -10,7 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/providers/anthropic"
"github.com/Gitlawb/zero/internal/providers/gemini"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -19,10 +18,6 @@ type Options struct {
UserAgent string
HTTPClient *http.Client
ModelRegistry *modelregistry.Registry
// OAuthResolver, when set, lets the provider authenticate model calls with an
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
}
// New creates a runtime provider for a resolved provider profile.
@@ -42,7 +37,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
@@ -57,7 +51,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
+4
View File
@@ -19,6 +19,10 @@ import (
const defaultBaseURL = "https://generativelanguage.googleapis.com"
const defaultMaxTokens = 8192
// providerName tags reasoning signatures this adapter binds to a tool call so
// only it replays them.
const providerName = "gemini"
// thinkingBudgetForEffort maps a requested reasoning effort to a Gemini thinking
// token budget, capped at 24576 (the lowest per-model ceiling among 2.5 models).
// 0 means "no thinking config" (leave the request unchanged).
+8 -16
View File
@@ -34,10 +34,6 @@ type Options struct {
CustomHeaders map[string]string
HTTPClient *http.Client
UserAgent string
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
// is preferred over APIKey; a nil resolver (or one that yields ok=false) uses
// the API key. See providerio.SendWithAuthRetry.
OAuthResolver providerio.TokenResolver
// MaxTokens caps the model's output tokens. Zero omits the cap (the model's
// own default applies). Resolved from the model registry by the factory.
MaxTokens int
@@ -58,7 +54,6 @@ type Provider struct {
authScheme string
authHeaderValue string
customHeaders map[string]string
oauthResolver providerio.TokenResolver
maxTokens int
httpClient *http.Client
userAgent string
@@ -105,7 +100,6 @@ func New(options Options) (*Provider, error) {
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
oauthResolver: options.OAuthResolver,
maxTokens: maxTokens,
httpClient: httpClient,
userAgent: options.UserAgent,
@@ -145,8 +139,12 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
// Retry transient failures (network errors, 429, and 5xx) before surfacing
// them — hosted gateways return intermittent 500s and rate limits that
// succeed on a quick retry. Shared with the Anthropic/Gemini providers.
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, endpoint, body,
providerio.AuthHeaders{
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, endpoint, body, func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
APIKey: provider.apiKey,
DefaultAuthHeader: "Authorization",
DefaultAuthScheme: "Bearer",
@@ -154,14 +152,8 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
AuthScheme: provider.authScheme,
AuthHeaderValue: provider.authHeaderValue,
CustomHeaders: provider.customHeaders,
},
provider.oauthResolver,
func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
}, 0)
})
}, 0)
if err != nil {
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
-84
View File
@@ -1,84 +0,0 @@
package providerio
import (
"context"
"net/http"
"strings"
)
// TokenResolver yields a fresh OAuth credential for one request, or ok=false to
// fall back to the API key (e.g. there is no OAuth login for this provider).
// header is the header name to set ("" => Authorization); value is the full
// header value (for example "Bearer abc"). forceRefresh asks the resolver to
// bypass any cached token; it is set only on the single retry after a 401.
type TokenResolver func(ctx context.Context, forceRefresh bool) (header string, value string, ok bool, err error)
// SendWithAuthRetry issues a request authenticated with an OAuth bearer (when the
// resolver yields one) or the API key, retrying ONCE with a force-refreshed token
// after an upstream 401. A 401 means the server rejected the request (auth
// failed) WITHOUT processing a completion, so replaying it after a refresh is
// safe — the same "no duplicate billable work" guarantee SendWithRetry relies on
// for 429/503. With a nil resolver (or one that yields ok=false) this behaves
// exactly like SendWithRetry with API-key auth.
//
// base carries the API-key auth config (key + default header/scheme). setExtra
// (optional) sets the provider's non-auth headers on every attempt. Transient
// retries (429/503/529) are handled by the inner SendWithRetry.
func SendWithAuthRetry(
ctx context.Context,
client *http.Client,
method string,
url string,
body []byte,
base AuthHeaders,
resolver TokenResolver,
setExtra func(*http.Request),
maxAttempts int,
) (*http.Response, error) {
forceRefresh := false
for authTry := 0; ; authTry++ {
// Resolve auth BEFORE dispatching: a resolver error inside the request
// callback would otherwise let SendWithRetry send an unauthenticated
// request (leaking the path/body) before we return the error.
headers := base
if resolver != nil {
header, value, ok, rerr := resolver(ctx, forceRefresh)
if rerr != nil {
return nil, rerr
}
if ok {
headers = withBearer(base, header, value)
}
}
response, err := SendWithRetry(ctx, client, method, url, body, func(request *http.Request) {
if setExtra != nil {
setExtra(request)
}
ApplyAuthHeaders(request, headers)
}, maxAttempts)
if err != nil {
return response, err
}
// One retry on 401 with a forced token refresh (rejected request → safe).
if resolver != nil && response.StatusCode == http.StatusUnauthorized && authTry == 0 {
_ = response.Body.Close()
forceRefresh = true
continue
}
return response, nil
}
}
// withBearer overrides base to use the OAuth credential: the given header
// (default Authorization) carries value verbatim, and the API key is cleared so
// the two auth methods can never both be sent.
func withBearer(base AuthHeaders, header, value string) AuthHeaders {
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
base.APIKey = ""
base.AuthHeader = header
base.AuthScheme = ""
base.AuthHeaderValue = value
return base
}
-197
View File
@@ -1,197 +0,0 @@
package providerio
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func drain(resp *http.Response) {
if resp != nil {
_ = resp.Body.Close()
}
}
func TestSendWithAuthRetryAPIKeyFallbackWhenNilResolver(t *testing.T) {
var gotKey, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("X-Api-Key")
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{APIKey: "KEY", DefaultAuthHeader: "X-Api-Key"}, nil, nil, 1)
defer drain(resp)
if err != nil {
t.Fatalf("SendWithAuthRetry: %v", err)
}
if gotKey != "KEY" {
t.Fatalf("X-Api-Key = %q, want KEY", gotKey)
}
if gotAuth != "" {
t.Fatalf("Authorization should be unset for API-key auth, got %q", gotAuth)
}
}
func TestSendWithAuthRetryResolverErrorDoesNotDispatch(t *testing.T) {
var hit int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hit, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
resolver := func(context.Context, bool) (string, string, bool, error) {
return "", "", false, errors.New("resolve failed")
}
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{APIKey: "KEY", DefaultAuthHeader: "X-Api-Key"}, resolver, nil, 1)
drain(resp)
if err == nil {
t.Fatal("expected the resolver error to surface")
}
if resp != nil {
t.Fatalf("no response should be returned on resolver error, got status %d", resp.StatusCode)
}
// The request must never reach the server when auth resolution failed.
if n := atomic.LoadInt32(&hit); n != 0 {
t.Fatalf("request dispatched %d times despite resolver error, want 0", n)
}
}
func TestSendWithAuthRetryBearerWinsAndNeverBoth(t *testing.T) {
var gotKey, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("X-Api-Key")
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
resolver := func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer TOK", true, nil
}
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{APIKey: "KEY", DefaultAuthHeader: "X-Api-Key"}, resolver, nil, 1)
defer drain(resp)
if err != nil {
t.Fatalf("SendWithAuthRetry: %v", err)
}
if gotAuth != "Bearer TOK" {
t.Fatalf("Authorization = %q, want Bearer TOK", gotAuth)
}
if gotKey != "" {
t.Fatalf("API key must NOT be sent alongside a bearer token, got X-Api-Key=%q", gotKey)
}
}
func TestSendWithAuthRetryOkFalseFallsBackToAPIKey(t *testing.T) {
var gotKey, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.Header.Get("X-Api-Key")
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// resolver yields ok=false (no login) → API key path.
resolver := func(context.Context, bool) (string, string, bool, error) { return "", "", false, nil }
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{APIKey: "KEY", DefaultAuthHeader: "X-Api-Key"}, resolver, nil, 1)
defer drain(resp)
if err != nil {
t.Fatalf("SendWithAuthRetry: %v", err)
}
if gotKey != "KEY" || gotAuth != "" {
t.Fatalf("ok=false should use API key: X-Api-Key=%q Authorization=%q", gotKey, gotAuth)
}
}
func TestSendWithAuthRetryRefreshesOn401(t *testing.T) {
var count int32
var first, second string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&count, 1)
if n == 1 {
first = r.Header.Get("Authorization")
w.WriteHeader(http.StatusUnauthorized)
return
}
second = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
var forced int32
resolver := func(_ context.Context, forceRefresh bool) (string, string, bool, error) {
if forceRefresh {
atomic.AddInt32(&forced, 1)
return "Authorization", "Bearer NEW", true, nil
}
return "Authorization", "Bearer OLD", true, nil
}
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{}, resolver, nil, 1)
defer drain(resp)
if err != nil {
t.Fatalf("SendWithAuthRetry: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("final status = %d, want 200", resp.StatusCode)
}
if first != "Bearer OLD" || second != "Bearer NEW" {
t.Fatalf("expected old→new bearer, got first=%q second=%q", first, second)
}
if atomic.LoadInt32(&forced) != 1 {
t.Fatalf("force-refresh called %d times, want 1", forced)
}
if atomic.LoadInt32(&count) != 2 {
t.Fatalf("server hit %d times, want 2", count)
}
}
func TestSendWithAuthRetryStopsAfterOne401Retry(t *testing.T) {
var count int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&count, 1)
w.WriteHeader(http.StatusUnauthorized) // always 401
}))
defer srv.Close()
resolver := func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer T", true, nil
}
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{}, resolver, nil, 1)
defer drain(resp)
if err != nil {
t.Fatalf("SendWithAuthRetry: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("final status = %d, want 401", resp.StatusCode)
}
if got := atomic.LoadInt32(&count); got != 2 {
t.Fatalf("server hit %d times, want exactly 2 (initial + one refresh retry)", got)
}
}
func TestSendWithAuthRetrySurfacesResolverError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
wantErr := errors.New("refresh failed")
resolver := func(context.Context, bool) (string, string, bool, error) { return "", "", false, wantErr }
resp, err := SendWithAuthRetry(context.Background(), srv.Client(), http.MethodPost, srv.URL, nil,
AuthHeaders{}, resolver, nil, 1)
defer drain(resp)
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
}
-77
View File
@@ -1,77 +0,0 @@
package sessions
import (
"testing"
"time"
)
func newTitleTestStore(t *testing.T) *Store {
t.Helper()
now := time.Date(2026, 6, 16, 9, 0, 0, 0, time.UTC)
return NewStore(StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Second)
return now
},
})
}
func TestUpdateTitle(t *testing.T) {
store := newTitleTestStore(t)
session, err := store.Create(CreateInput{Title: "first message title"})
if err != nil {
t.Fatalf("create: %v", err)
}
// An append bumps UpdatedAt and EventCount; UpdateTitle must preserve both.
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{
Type: EventMessage,
Payload: map[string]any{"role": "user", "content": "hi"},
}); err != nil {
t.Fatalf("append: %v", err)
}
before, err := store.Get(session.SessionID)
if err != nil || before == nil {
t.Fatalf("get before: %v", err)
}
updated, err := store.UpdateTitle(session.SessionID, " Clean Generated Title ")
if err != nil {
t.Fatalf("update title: %v", err)
}
if updated.Title != "Clean Generated Title" {
t.Fatalf("title not trimmed/stored: %q", updated.Title)
}
if updated.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt must not change on retitle: before=%q after=%q", before.UpdatedAt, updated.UpdatedAt)
}
if updated.EventCount != before.EventCount {
t.Fatalf("EventCount changed: before=%d after=%d", before.EventCount, updated.EventCount)
}
persisted, err := store.Get(session.SessionID)
if err != nil || persisted == nil {
t.Fatalf("get after: %v", err)
}
if persisted.Title != "Clean Generated Title" {
t.Fatalf("persisted title = %q, want %q", persisted.Title, "Clean Generated Title")
}
// A blank title is rejected and must not erase the existing one.
if _, err := store.UpdateTitle(session.SessionID, " "); err == nil {
t.Fatal("expected a blank title to be rejected")
}
if got, _ := store.Get(session.SessionID); got == nil || got.Title != "Clean Generated Title" {
t.Fatalf("title must survive a rejected blank update, got %#v", got)
}
// An unchanged title is a no-op (still succeeds).
if _, err := store.UpdateTitle(session.SessionID, "Clean Generated Title"); err != nil {
t.Fatalf("no-op retitle should succeed: %v", err)
}
// An invalid session id is rejected.
if _, err := store.UpdateTitle("../escape", "whatever"); err == nil {
t.Fatal("expected an invalid session id to be rejected")
}
}
-36
View File
@@ -576,42 +576,6 @@ func (store *Store) appendEventLocked(sessionID string, input AppendEventInput)
return event, nil
}
// UpdateTitle replaces a session's Title and returns the updated metadata. It is
// serialized under the same per-session lock as AppendEvent and re-reads the
// latest metadata under that lock before rewriting, so a concurrent append can't
// clobber the new title (nor the title clobber a concurrent append's event
// count/timestamp). UpdatedAt is deliberately left untouched: a retitle is not
// activity, so it must not reorder the session in the resumable list. A blank
// title is rejected so a failed model generation can never erase a useful
// first-message title, and an unchanged title is a no-op (no rewrite/fsync).
func (store *Store) UpdateTitle(sessionID string, title string) (Metadata, error) {
if !ValidSessionID(sessionID) {
return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID)
}
trimmed := strings.TrimSpace(title)
if trimmed == "" {
return Metadata{}, fmt.Errorf("zero session title is required")
}
unlock, err := store.lockSession(sessionID)
if err != nil {
return Metadata{}, err
}
defer unlock()
session, err := store.readMetadata(sessionID)
if err != nil {
return Metadata{}, err
}
if session.Title == trimmed {
return session, nil
}
session.Title = trimmed
if err := store.writeMetadata(session); err != nil {
return Metadata{}, err
}
return session, nil
}
func (store *Store) ReadEvents(sessionID string) ([]Event, error) {
if !ValidSessionID(sessionID) {
return nil, fmt.Errorf("invalid zero session id %q", sessionID)
-3
View File
@@ -378,9 +378,6 @@ Prompt.`)
}
func TestKnownToolNamesMatchCoreRegistry(t *testing.T) {
// web_search is only registered when a search backend is configured; set one so
// CoreTools() exposes the full set this list is meant to mirror.
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
core := tools.CoreTools(t.TempDir())
got := make([]string, 0, len(knownToolNames))
for name := range knownToolNames {
-241
View File
@@ -1,241 +0,0 @@
package swarm
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/tools"
)
// ScheduleToolName is the recurring-spawn scheduler tool.
const ScheduleToolName = "swarm_schedule"
// ---- swarm_schedule --------------------------------------------------------
type scheduleTool struct {
sw *Swarm
now func() time.Time // injectable for tests; nil => time.Now
}
func (t *scheduleTool) Name() string { return ScheduleToolName }
func (t *scheduleTool) Description() string {
return "Manage recurring swarm spawns. action=add schedules an agent to spawn on an interval (every, e.g. \"30m\") or daily (daily_at \"HH:MM\"); action=list shows active schedules; action=cancel stops one by job_id. Each fire spawns a fresh member; a fire is skipped while the job's previous spawn is still running."
}
func (t *scheduleTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"action": {Type: "string", Description: "add (default), list, or cancel."},
"agent_type": {Type: "string", Description: "add: roster agent type to spawn each fire."},
"task": {Type: "string", Description: "add: the task/briefing handed to each spawned member."},
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
"every": {Type: "string", Description: "add: interval between fires as a Go duration (e.g. \"30m\", \"2h\"). Minimum 1s. Mutually exclusive with daily_at."},
"daily_at": {Type: "string", Description: "add: local time of day \"HH:MM\" to fire once per day. Mutually exclusive with every."},
"first_delay": {Type: "string", Description: "add: optional delay before the first fire (Go duration). Ignored with daily_at."},
"max_runs": {Type: "integer", Description: "add: stop after this many successful spawns. 0 or omitted => unbounded."},
"job_id": {Type: "string", Description: "cancel: the schedule id to stop."},
},
Required: []string{},
AdditionalProperties: false,
}
}
func (t *scheduleTool) Safety() tools.Safety {
return tools.Safety{
// Adding a schedule spawns members repeatedly over time, so the tool is
// classified like swarm_spawn (prompts) even though list/cancel are light.
SideEffect: tools.SideEffectShell,
Permission: tools.PermissionPrompt,
Reason: "Schedules recurring swarm member spawns under the orchestrator's sandbox and policy.",
AdvertiseInAuto: true,
}
}
func (t *scheduleTool) Run(ctx context.Context, args map[string]any) tools.Result {
return t.RunWithOptions(ctx, args, tools.RunOptions{})
}
func (t *scheduleTool) RunWithOptions(_ context.Context, args map[string]any, options tools.RunOptions) tools.Result {
action := strings.ToLower(swarmStr(args, "action"))
if action == "" {
action = "add"
}
switch action {
case "add":
return t.add(args, options)
case "list":
return t.list()
case "cancel":
return t.cancel(args)
default:
return errResult("swarm_schedule: unknown action %q (want add, list, or cancel)", action)
}
}
func (t *scheduleTool) add(args map[string]any, options tools.RunOptions) tools.Result {
agentType := swarmStr(args, "agent_type")
task := swarmStr(args, "task")
if agentType == "" {
return errResult("swarm_schedule add requires agent_type")
}
if task == "" {
return errResult("swarm_schedule add requires task")
}
every := swarmStr(args, "every")
dailyAt := swarmStr(args, "daily_at")
if every == "" && dailyAt == "" {
return errResult("swarm_schedule add requires every or daily_at")
}
if every != "" && dailyAt != "" {
return errResult("swarm_schedule add: every and daily_at are mutually exclusive")
}
var sch Schedule
switch {
case dailyAt != "":
hour, minute, err := parseClock(dailyAt)
if err != nil {
return errResult("%v", err)
}
// Daily mode: Every=24h satisfies validation/display, but the scheduler
// recomputes each fire from Hour:Minute so the wall-clock time holds across
// DST instead of drifting by a fixed 24h.
sch.Every = 24 * time.Hour
sch.Daily = true
sch.Hour = hour
sch.Minute = minute
sch.FirstDelay = nextDailyDelay(t.clock(), hour, minute)
default:
d, err := time.ParseDuration(every)
if err != nil {
return errResult("swarm_schedule add: invalid every %q: %v", every, err)
}
sch.Every = d
if fd := swarmStr(args, "first_delay"); fd != "" {
delay, err := time.ParseDuration(fd)
if err != nil {
return errResult("swarm_schedule add: invalid first_delay %q: %v", fd, err)
}
if delay < 0 {
return errResult("swarm_schedule add: first_delay must be >= 0")
}
sch.FirstDelay = delay
}
}
if mr, ok := swarmInt(args, "max_runs"); ok {
sch.MaxRuns = mr
}
team := swarmStr(args, "team")
id, err := t.sw.Scheduler().Add(policyFrom(options), team, agentType, task, options.Cwd, sch)
if err != nil {
if errors.Is(err, ErrUnknownAgentType) {
return errResult("%v; available agent types: %s", err, strings.Join(t.sw.Registry().AgentTypes(), ", "))
}
return errResult("%v", err)
}
cadence := "every " + sch.Every.String()
if dailyAt != "" {
cadence = "daily at " + dailyAt
}
out := fmt.Sprintf("Scheduled %s as %s on team %s (%s).", agentType, id, displayTeam(team), cadence)
res := okResult(out, "swarm", out)
res.Meta = map[string]string{"job_id": id, "team": sanitizeName(team), "agent_type": agentType}
return res
}
func (t *scheduleTool) list() tools.Result {
jobs := t.sw.Scheduler().List()
sort.Slice(jobs, func(i, j int) bool { return jobs[i].ID < jobs[j].ID })
var b strings.Builder
fmt.Fprintf(&b, "Scheduled jobs: %d\n", len(jobs))
for _, j := range jobs {
maxRuns := "unbounded"
if j.MaxRuns > 0 {
maxRuns = strconv.Itoa(j.MaxRuns)
}
fmt.Fprintf(&b, " - %s [%s/%s] every %s, runs %d (max %s), skipped %d: %s\n",
j.ID, j.AgentType, j.Team, j.Every, j.Runs, maxRuns, j.Skipped, collapse(j.Task))
}
out := strings.TrimRight(b.String(), "\n")
return okResult(out, "swarm", fmt.Sprintf("%d scheduled job(s)", len(jobs)))
}
func (t *scheduleTool) cancel(args map[string]any) tools.Result {
id := swarmStr(args, "job_id")
if id == "" {
return errResult("swarm_schedule cancel requires job_id")
}
if !t.sw.Scheduler().Cancel(id) {
return errResult("swarm_schedule: no such job %q", id)
}
out := fmt.Sprintf("Cancelled scheduled job %s.", id)
return okResult(out, "swarm", out)
}
func (t *scheduleTool) clock() time.Time {
if t.now != nil {
return t.now()
}
return time.Now()
}
// parseClock parses a 24-hour "HH:MM" local time of day.
func parseClock(s string) (hour, minute int, err error) {
parts := strings.Split(strings.TrimSpace(s), ":")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("swarm_schedule: invalid daily_at %q (want HH:MM)", s)
}
hour, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil || hour < 0 || hour > 23 {
return 0, 0, fmt.Errorf("swarm_schedule: invalid hour in daily_at %q (want 00-23)", s)
}
minute, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil || minute < 0 || minute > 59 {
return 0, 0, fmt.Errorf("swarm_schedule: invalid minute in daily_at %q (want 00-59)", s)
}
return hour, minute, nil
}
// swarmInt reads an integer argument, accepting JSON numbers or numeric strings.
func swarmInt(args map[string]any, key string) (int, bool) {
if args == nil {
return 0, false
}
v, ok := args[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
// Reject non-integer / non-finite JSON numbers so e.g. max_runs=1.9 is an
// error rather than silently truncating to 1.
if math.IsNaN(n) || math.IsInf(n, 0) || math.Trunc(n) != n {
return 0, false
}
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case string:
s := strings.TrimSpace(n)
if s == "" {
return 0, false
}
i, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return i, true
}
return 0, false
}
-119
View File
@@ -1,119 +0,0 @@
package swarm
import (
"context"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
)
func TestScheduleToolAddListCancel(t *testing.T) {
reg, sw := newToolSwarm(t, newLauncher(okFor))
ctx := context.Background()
grant := tools.RunOptions{PermissionGranted: true, Model: "m1", Cwd: "/work"}
// add (24h interval so the real timer never fires during the test)
res := reg.RunWithOptions(ctx, ScheduleToolName, map[string]any{
"agent_type": "teammate",
"task": "nightly sweep",
"team": "alpha",
"every": "24h",
"max_runs": float64(3),
}, grant)
if res.Status != tools.StatusOK {
t.Fatalf("add status = %v, output=%q", res.Status, res.Output)
}
jobID := res.Meta["job_id"]
if jobID == "" {
t.Fatal("add must return a job_id in Meta")
}
// list shows it
res = reg.RunWithOptions(ctx, ScheduleToolName, map[string]any{"action": "list"}, grant)
if res.Status != tools.StatusOK || !strings.Contains(res.Output, jobID) {
t.Fatalf("list missing job %q: status=%v output=%q", jobID, res.Status, res.Output)
}
// cancel removes it
res = reg.RunWithOptions(ctx, ScheduleToolName, map[string]any{"action": "cancel", "job_id": jobID}, grant)
if res.Status != tools.StatusOK {
t.Fatalf("cancel status = %v, output=%q", res.Status, res.Output)
}
if got := len(sw.Scheduler().List()); got != 0 {
t.Fatalf("after cancel, %d jobs remain, want 0", got)
}
// cancel again is an error
res = reg.RunWithOptions(ctx, ScheduleToolName, map[string]any{"action": "cancel", "job_id": jobID}, grant)
if res.Status != tools.StatusError {
t.Fatalf("cancel of gone job should error, got %v", res.Status)
}
}
func TestScheduleToolDailyAt(t *testing.T) {
reg, sw := newToolSwarm(t, newLauncher(okFor))
// Make the daily-time math deterministic by injecting a fixed clock into the
// tool. The registry already holds a scheduleTool; replace it with one whose
// clock is fixed so first_delay is computed against a known "now".
st := &scheduleTool{sw: sw, now: func() time.Time { return time.Date(2026, 6, 15, 8, 0, 0, 0, time.UTC) }}
reg.Register(st)
res := reg.RunWithOptions(context.Background(), ScheduleToolName, map[string]any{
"agent_type": "teammate",
"task": "report",
"daily_at": "23:30",
}, tools.RunOptions{PermissionGranted: true})
if res.Status != tools.StatusOK {
t.Fatalf("daily_at add status = %v, output=%q", res.Status, res.Output)
}
jobs := sw.Scheduler().List()
if len(jobs) != 1 {
t.Fatalf("want 1 scheduled job, got %d", len(jobs))
}
if jobs[0].Every != 24*time.Hour {
t.Fatalf("daily_at should schedule a 24h interval, got %s", jobs[0].Every)
}
}
func TestScheduleToolValidation(t *testing.T) {
reg, _ := newToolSwarm(t, newLauncher(okFor))
ctx := context.Background()
grant := tools.RunOptions{PermissionGranted: true}
cases := []struct {
name string
args map[string]any
}{
{"missing agent_type", map[string]any{"task": "t", "every": "1h"}},
{"missing task", map[string]any{"agent_type": "teammate", "every": "1h"}},
{"neither every nor daily_at", map[string]any{"agent_type": "teammate", "task": "t"}},
{"both every and daily_at", map[string]any{"agent_type": "teammate", "task": "t", "every": "1h", "daily_at": "10:00"}},
{"invalid every", map[string]any{"agent_type": "teammate", "task": "t", "every": "nope"}},
{"sub-second every", map[string]any{"agent_type": "teammate", "task": "t", "every": "500ms"}},
{"unknown agent_type", map[string]any{"agent_type": "ghost", "task": "t", "every": "1h"}},
{"invalid daily_at", map[string]any{"agent_type": "teammate", "task": "t", "daily_at": "99:99"}},
{"unknown action", map[string]any{"action": "frobnicate"}},
{"cancel without id", map[string]any{"action": "cancel"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := reg.RunWithOptions(ctx, ScheduleToolName, tc.args, grant)
if res.Status != tools.StatusError {
t.Fatalf("expected error, got status=%v output=%q", res.Status, res.Output)
}
})
}
}
func TestScheduleToolRequiresPermission(t *testing.T) {
reg, _ := newToolSwarm(t, newLauncher(okFor))
// swarm_schedule is a prompt tool: without a grant the registry refuses it.
res := reg.RunWithOptions(context.Background(), ScheduleToolName, map[string]any{
"agent_type": "teammate", "task": "t", "every": "1h",
}, tools.RunOptions{})
if res.Status != tools.StatusError {
t.Fatalf("schedule without permission should error, got %v", res.Status)
}
}
-345
View File
@@ -1,345 +0,0 @@
package swarm
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
)
// minScheduleInterval is the floor for a recurring spawn. Scheduling is opt-in
// and additive, but a tight loop could still flood a team's queue, so the
// shortest permitted interval is one second.
const minScheduleInterval = time.Second
// Schedule describes when a scheduled job fires. Scheduling is interval-based
// ("wakeup"): the job first fires after FirstDelay (or Every when FirstDelay is
// zero), then every Every interval, until MaxRuns successful spawns is reached
// or the job/scheduler is stopped. A daily "cron" time sets Daily with Hour/Minute
// (and Every=24h for display/validation); the run loop then recomputes the delay
// to the next local HH:MM each cycle so it holds across DST (see the
// swarm_schedule tool's daily_at handling).
type Schedule struct {
// Every is the interval between fires. Required, must be >= minScheduleInterval.
Every time.Duration
// FirstDelay delays the first fire. Zero => the first fire happens after Every.
FirstDelay time.Duration
// MaxRuns bounds successful spawns. Zero => unbounded (until cancelled).
MaxRuns int
// Daily, when set, recomputes each fire as the next local Hour:Minute rather
// than adding a fixed Every, so a wall-clock daily time does not drift across
// DST transitions.
Daily bool
Hour int
Minute int
}
func (sch Schedule) validate() error {
if sch.Every < minScheduleInterval {
return fmt.Errorf("swarm: schedule interval must be >= %s", minScheduleInterval)
}
if sch.MaxRuns < 0 {
return errors.New("swarm: schedule max_runs must be >= 0")
}
return nil
}
// tickerFunc returns a channel that delivers one tick after d, plus a stop func
// that releases the underlying timer. Production uses realTicker; tests inject a
// controllable source. It mirrors time.NewTimer's one-shot semantics: the run
// loop requests a fresh ticker for each interval.
type tickerFunc func(d time.Duration) (<-chan time.Time, func())
func realTicker(d time.Duration) (<-chan time.Time, func()) {
t := time.NewTimer(d)
return t.C, func() { t.Stop() }
}
// JobStatus is a read-only snapshot of a scheduled job for listing.
type JobStatus struct {
ID string
Team string
AgentType string
Task string
Every time.Duration
MaxRuns int
Runs int
// Skipped counts fires that did not spawn — either because the job's previous
// spawn was still running (non-overlap) or a spawn attempt errored.
Skipped int
}
// scheduledJob is one recurring spawn. Its goroutine owns the timing loop; mu
// guards the mutable counters and the last-spawned task id used for non-overlap.
type scheduledJob struct {
id string
schedule Schedule
policy Policy
team string
agentType string
task string
cwd string
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
runs int
skipped int
lastTask string
}
func (j *scheduledJob) snapshot() JobStatus {
j.mu.Lock()
defer j.mu.Unlock()
return JobStatus{
ID: j.id,
Team: j.team,
AgentType: j.agentType,
Task: j.task,
Every: j.schedule.Every,
MaxRuns: j.schedule.MaxRuns,
Runs: j.runs,
Skipped: j.skipped,
}
}
// Scheduler fires recurring swarm spawns. It is opt-in: nothing runs unless a
// job is explicitly added via Add, and Close stops every job. Each job spawns a
// fresh member per interval through the same Swarm.Spawn path, so members
// inherit the recorded policy and are bounded by the team's slot cap and queue.
type Scheduler struct {
sw *Swarm
newTicker tickerFunc
// now is the wall clock used to recompute a daily job's next local fire each
// cycle (so HH:MM holds across DST). Defaults to time.Now; tests override it.
now func() time.Time
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
jobs map[string]*scheduledJob
closed bool
seq atomic.Uint64
wg sync.WaitGroup
}
// newScheduler builds a Scheduler bound to sw. Its context derives from the
// Swarm's base context, so closing the Swarm also stops every scheduled job.
func newScheduler(sw *Swarm) *Scheduler {
ctx, cancel := context.WithCancel(sw.baseCtx)
return &Scheduler{
sw: sw,
newTicker: realTicker,
now: time.Now,
ctx: ctx,
cancel: cancel,
jobs: map[string]*scheduledJob{},
}
}
// clock returns the scheduler's wall clock, defaulting to time.Now.
func (s *Scheduler) clock() time.Time {
if s.now != nil {
return s.now()
}
return time.Now()
}
// Add registers a recurring spawn and starts its timing loop. It validates the
// schedule and the agent type up front (fail fast) so a bad job never starts.
// The returned id identifies the job for List/Cancel.
func (s *Scheduler) Add(pol Policy, teamName, agentType, task, cwd string, sch Schedule) (string, error) {
if err := sch.validate(); err != nil {
return "", err
}
if _, err := s.sw.registry.Lookup(agentType); err != nil {
return "", err
}
task = strings.TrimSpace(task)
if task == "" {
return "", errors.New("swarm: schedule requires a task")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return "", errors.New("swarm: scheduler is closed")
}
// A scheduler whose parent context is already canceled (e.g. after
// Swarm.Close) is effectively closed: a new job's loop would exit on the
// first select, so reject Add rather than reporting a job that never runs.
select {
case <-s.ctx.Done():
return "", errors.New("swarm: scheduler is closed")
default:
}
id := fmt.Sprintf("sched-%d", s.seq.Add(1))
ctx, cancel := context.WithCancel(s.ctx)
job := &scheduledJob{
id: id,
schedule: sch,
policy: pol,
team: sanitizeName(teamName),
agentType: agentType,
task: task,
cwd: cwd,
ctx: ctx,
cancel: cancel,
}
s.jobs[id] = job
s.wg.Add(1)
go s.run(job)
return id, nil
}
// Cancel stops a scheduled job by id. It reports whether a job was found.
func (s *Scheduler) Cancel(id string) bool {
s.mu.Lock()
job, ok := s.jobs[id]
if ok {
delete(s.jobs, id)
}
s.mu.Unlock()
if ok {
job.cancel()
}
return ok
}
// List returns a snapshot of every active scheduled job.
func (s *Scheduler) List() []JobStatus {
s.mu.Lock()
jobs := make([]*scheduledJob, 0, len(s.jobs))
for _, j := range s.jobs {
jobs = append(jobs, j)
}
s.mu.Unlock()
out := make([]JobStatus, 0, len(jobs))
for _, j := range jobs {
out = append(out, j.snapshot())
}
return out
}
// Close cancels every job and waits for their loops to exit. Safe to call more
// than once.
func (s *Scheduler) Close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.cancel()
s.mu.Unlock()
s.wg.Wait()
}
// run is one job's timing loop. It requests a fresh one-shot ticker per interval
// and fires until cancelled or MaxRuns is reached.
func (s *Scheduler) run(job *scheduledJob) {
defer s.wg.Done()
defer s.forget(job.id)
delay := job.schedule.FirstDelay
if delay <= 0 {
delay = job.schedule.Every
}
for {
ch, stop := s.newTicker(delay)
select {
case <-job.ctx.Done():
stop()
return
case <-ch:
stop()
}
// A tick and a cancel can be ready together; the select above may pick the
// tick, so re-check cancellation before firing to avoid one extra spawn
// after Cancel/Close.
select {
case <-job.ctx.Done():
return
default:
}
// Daily jobs recompute the delay to the next local HH:MM each cycle so the
// wall-clock time holds across DST; interval jobs use the fixed Every.
if job.schedule.Daily {
delay = nextDailyDelay(s.clock(), job.schedule.Hour, job.schedule.Minute)
} else {
delay = job.schedule.Every
}
if !s.fireIfIdle(job) {
continue
}
runs := job.incRuns()
if max := job.schedule.MaxRuns; max > 0 && runs >= max {
return
}
}
}
// fireIfIdle spawns a fresh member unless the job's previous spawn is still
// running (non-overlap). It returns whether a spawn occurred. A spawn error is
// treated like a skip so a transient failure never tears down the loop.
func (s *Scheduler) fireIfIdle(job *scheduledJob) bool {
job.mu.Lock()
last := job.lastTask
job.mu.Unlock()
if last != "" {
if t, ok := s.sw.coord.Get(last); ok && !t.Status.terminal() {
job.incSkipped()
return false
}
}
id, err := s.sw.Spawn(job.policy, job.team, job.agentType, job.task, job.cwd)
if err != nil {
job.incSkipped()
return false
}
job.mu.Lock()
job.lastTask = id
job.mu.Unlock()
return true
}
func (j *scheduledJob) incRuns() int {
j.mu.Lock()
defer j.mu.Unlock()
j.runs++
return j.runs
}
func (j *scheduledJob) incSkipped() {
j.mu.Lock()
j.skipped++
j.mu.Unlock()
}
func (s *Scheduler) forget(id string) {
s.mu.Lock()
delete(s.jobs, id)
s.mu.Unlock()
}
// nextDailyDelay returns the duration from now until the next occurrence of the
// given local hour:minute (today if still ahead, otherwise tomorrow). It backs
// the swarm_schedule tool's daily_at ("cron"-style) option.
func nextDailyDelay(now time.Time, hour, minute int) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !next.After(now) {
// Roll to the same wall-clock time on the next calendar day via day+1
// (not Add(24h)): time.Date normalizes the date and applies the correct
// local offset, so the HH:MM holds across DST (a spring-forward/fall-back
// day is 23h/25h, and a fixed 24h would fire an hour early/late).
next = time.Date(now.Year(), now.Month(), now.Day()+1, hour, minute, 0, 0, now.Location())
}
return next.Sub(now)
}
-267
View File
@@ -1,267 +0,0 @@
package swarm
import (
"math"
"testing"
"time"
)
// testTicker returns a ticker factory backed by a single unbounded-handshake
// channel: each send on ticks unblocks exactly one run-loop iteration, so a test
// drives fires deterministically with no real time.
func testTicker(ticks chan time.Time) tickerFunc {
return func(time.Duration) (<-chan time.Time, func()) {
return ticks, func() {}
}
}
func findJob(jobs []JobStatus, id string) (JobStatus, bool) {
for _, j := range jobs {
if j.ID == id {
return j, true
}
}
return JobStatus{}, false
}
func TestSchedulerFiresAndCountsRuns(t *testing.T) {
l := newLauncher(okFor) // members complete immediately
sw := newSwarmFor(t, l)
sched := sw.Scheduler()
ticks := make(chan time.Time)
sched.newTicker = testTicker(ticks)
id, err := sched.Add(Policy{Model: "m"}, "team", "teammate", "ping", "", Schedule{Every: time.Hour, MaxRuns: 3})
if err != nil {
t.Fatalf("Add: %v", err)
}
for i := 0; i < 3; i++ {
ticks <- time.Time{}
want := i + 1
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == want })
}
// After MaxRuns the job retires itself.
waitFor(t, "job retired", func() bool { _, ok := findJob(sched.List(), id); return !ok })
if got := len(l.recorded()); got != 3 {
t.Fatalf("spawned %d members, want 3", got)
}
if done := sw.Coordinator().Summarize().Done; done != 3 {
t.Fatalf("done = %d, want 3", done)
}
}
func TestSchedulerSkipsWhilePreviousRuns(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate // members block until released
sw := newSwarmFor(t, l)
sched := sw.Scheduler()
ticks := make(chan time.Time)
sched.newTicker = testTicker(ticks)
id, err := sched.Add(Policy{Model: "m"}, "team", "teammate", "ping", "", Schedule{Every: time.Hour})
if err != nil {
t.Fatalf("Add: %v", err)
}
// Fire 1: spawns and the member stays running (gated).
ticks <- time.Time{}
waitFor(t, "first spawn", func() bool { return len(l.recorded()) == 1 })
// Fire 2: previous still running => skipped, no new spawn.
ticks <- time.Time{}
waitFor(t, "skip recorded", func() bool {
j, ok := findJob(sched.List(), id)
return ok && j.Skipped == 1
})
if got := len(l.recorded()); got != 1 {
t.Fatalf("a skip must not spawn: recorded %d, want 1", got)
}
// Release the first member, then fire 3: previous terminal => spawns again.
close(gate)
waitFor(t, "first done", func() bool { return sw.Coordinator().Summarize().Done == 1 })
ticks <- time.Time{}
waitFor(t, "second spawn", func() bool { return len(l.recorded()) == 2 })
j, ok := findJob(sched.List(), id)
if !ok {
t.Fatal("job should still be active (unbounded)")
}
if j.Runs != 2 || j.Skipped != 1 {
t.Fatalf("job runs=%d skipped=%d, want 2/1", j.Runs, j.Skipped)
}
}
func TestSchedulerAddValidation(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
sched := sw.Scheduler()
if _, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Millisecond}); err == nil {
t.Fatal("sub-second interval should be rejected")
}
if _, err := sched.Add(Policy{}, "team", "nope", "t", "", Schedule{Every: time.Hour}); err == nil {
t.Fatal("unknown agent type should be rejected")
}
if _, err := sched.Add(Policy{}, "team", "teammate", " ", "", Schedule{Every: time.Hour}); err == nil {
t.Fatal("empty task should be rejected")
}
if _, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Hour, MaxRuns: -1}); err == nil {
t.Fatal("negative max_runs should be rejected")
}
}
func TestSchedulerCancel(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
sched := sw.Scheduler()
ticks := make(chan time.Time)
sched.newTicker = testTicker(ticks) // never ticked => never fires
id, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Hour})
if err != nil {
t.Fatalf("Add: %v", err)
}
if !sched.Cancel(id) {
t.Fatal("Cancel of an active job should return true")
}
if _, ok := findJob(sched.List(), id); ok {
t.Fatal("cancelled job should be gone from List")
}
if sched.Cancel(id) {
t.Fatal("Cancel of an unknown job should return false")
}
}
func TestSchedulerCloseStopsJobs(t *testing.T) {
sw, err := New(Options{BaseDir: t.TempDir(), Launcher: newLauncher(okFor)})
if err != nil {
t.Fatalf("New: %v", err)
}
sched := sw.Scheduler()
ticks := make(chan time.Time)
sched.newTicker = testTicker(ticks)
for i := 0; i < 3; i++ {
if _, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Hour}); err != nil {
t.Fatalf("Add: %v", err)
}
}
sw.Close() // must return (wg waits) and leave no active jobs
if got := len(sched.List()); got != 0 {
t.Fatalf("after Close, %d jobs remain, want 0", got)
}
if _, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Hour}); err == nil {
t.Fatal("Add after Close should fail")
}
}
func TestSchedulerAddRejectedAfterContextCancel(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
sched := sw.Scheduler()
// Parent context canceled but s.closed not yet set: Add must still refuse so it
// never reports a job whose loop exits immediately.
sched.cancel()
if _, err := sched.Add(Policy{}, "team", "teammate", "t", "", Schedule{Every: time.Hour}); err == nil {
t.Fatal("Add after the scheduler context is canceled should fail")
}
}
func TestSchedulerDailyRecomputesNextDelay(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
sched := sw.Scheduler()
ticks := make(chan time.Time)
var delays []time.Duration
sched.newTicker = func(d time.Duration) (<-chan time.Time, func()) {
delays = append(delays, d)
return ticks, func() {}
}
// Freeze the clock at 10:00 local so the next 11:00 is deterministically 1h —
// not the fixed 24h Every. This is what guards against DST drift.
base := time.Date(2026, 1, 1, 10, 0, 0, 0, time.Local)
sched.now = func() time.Time { return base }
if _, err := sched.Add(Policy{Model: "m"}, "team", "teammate", "ping", "",
Schedule{Every: 24 * time.Hour, Daily: true, Hour: 11, Minute: 0, FirstDelay: nextDailyDelay(base, 11, 0), MaxRuns: 2}); err != nil {
t.Fatalf("Add: %v", err)
}
for i := 0; i < 2; i++ {
ticks <- time.Time{}
waitFor(t, "task completed", func() bool { return sw.Coordinator().Summarize().Done == i+1 })
}
if len(delays) < 2 {
t.Fatalf("expected at least two requested delays, got %v", delays)
}
// The second iteration's delay is recomputed from the clock (1h), not Every (24h).
if delays[1] != time.Hour {
t.Fatalf("recomputed daily delay = %s, want 1h (next 11:00 from frozen 10:00)", delays[1])
}
}
func TestNextDailyDelay(t *testing.T) {
loc := time.UTC
now := time.Date(2026, 6, 15, 10, 0, 0, 0, loc)
if got := nextDailyDelay(now, 11, 0); got != time.Hour {
t.Fatalf("ahead-today delay = %s, want 1h", got)
}
if got := nextDailyDelay(now, 9, 0); got != 23*time.Hour {
t.Fatalf("passed-today delay = %s, want 23h", got)
}
// Exactly now => next is tomorrow (strictly-after guard).
if got := nextDailyDelay(now, 10, 0); got != 24*time.Hour {
t.Fatalf("equal-now delay = %s, want 24h", got)
}
}
func TestNextDailyDelayHoldsWallClockAcrossDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Skipf("tzdata unavailable: %v", err)
}
// US spring-forward 2026-03-08: clocks jump 02:00 -> 03:00 (a 23-hour day).
// At 06:00 EDT the 01:30 target has passed, so the next fire must be 01:30
// local tomorrow; a fixed Add(24h) would land at 02:30 instead.
now := time.Date(2026, 3, 8, 6, 0, 0, 0, loc)
got := now.Add(nextDailyDelay(now, 1, 30))
if got.Day() != 9 || got.Hour() != 1 || got.Minute() != 30 {
t.Fatalf("next fire = %s, want 2026-03-09 01:30 local (DST-safe)", got.Format("2006-01-02 15:04 MST"))
}
}
func TestParseClock(t *testing.T) {
h, m, err := parseClock(" 09:30 ")
if err != nil || h != 9 || m != 30 {
t.Fatalf("parseClock(09:30) = %d,%d,%v", h, m, err)
}
for _, bad := range []string{"24:00", "10:60", "9", "x:y", "10:", ":30", "-1:00"} {
if _, _, err := parseClock(bad); err == nil {
t.Fatalf("parseClock(%q) should error", bad)
}
}
}
func TestSwarmInt(t *testing.T) {
if v, ok := swarmInt(map[string]any{"n": float64(5)}, "n"); !ok || v != 5 {
t.Fatalf("float64 => %d,%v", v, ok)
}
if v, ok := swarmInt(map[string]any{"n": "7"}, "n"); !ok || v != 7 {
t.Fatalf("string => %d,%v", v, ok)
}
if _, ok := swarmInt(map[string]any{"n": "x"}, "n"); ok {
t.Fatal("non-numeric string should not parse")
}
if _, ok := swarmInt(nil, "n"); ok {
t.Fatal("nil args should not parse")
}
// A non-integer JSON number must be rejected, not truncated to an int.
if v, ok := swarmInt(map[string]any{"n": 1.9}, "n"); ok {
t.Fatalf("non-integer float should not parse, got %d", v)
}
if _, ok := swarmInt(map[string]any{"n": math.Inf(1)}, "n"); ok {
t.Fatal("infinity should not parse")
}
if _, ok := swarmInt(map[string]any{"n": math.NaN()}, "n"); ok {
t.Fatal("NaN should not parse")
}
}
+5 -24
View File
@@ -64,11 +64,10 @@ type Swarm struct {
baseCtx context.Context
cancel context.CancelFunc
mu sync.Mutex
teams map[string]*Team
taskCwd map[string]string // taskID -> cwd, for handoff/adoption relaunch
scheduler *Scheduler // lazily created by Scheduler(); nil until first use
idSeq atomic.Uint64
mu sync.Mutex
teams map[string]*Team
taskCwd map[string]string // taskID -> cwd, for handoff/adoption relaunch
idSeq atomic.Uint64
}
// Team is a named set of concurrently-running members with a bounded slot count
@@ -137,31 +136,13 @@ func New(opts Options) (*Swarm, error) {
}
// Close cancels every running member's context and releases resources. It is
// safe to call more than once. The scheduler is closed first so no new spawn
// fires after shutdown begins.
// safe to call more than once.
func (s *Swarm) Close() {
s.mu.Lock()
sched := s.scheduler
s.mu.Unlock()
if sched != nil {
sched.Close()
}
if s.cancel != nil {
s.cancel()
}
}
// Scheduler returns the swarm's recurring-spawn scheduler, creating it on first
// use. Scheduling is opt-in: until a job is added the scheduler does nothing.
func (s *Swarm) Scheduler() *Scheduler {
s.mu.Lock()
defer s.mu.Unlock()
if s.scheduler == nil {
s.scheduler = newScheduler(s)
}
return s.scheduler
}
// rememberCwd records a task's working dir so a handoff/adoption relaunch keeps it.
func (s *Swarm) rememberCwd(taskID, cwd string) {
s.mu.Lock()
-1
View File
@@ -30,7 +30,6 @@ func RegisterTools(registry *tools.Registry, sw *Swarm) {
registry.Register(&statusTool{sw: sw})
registry.Register(&handoffTool{sw: sw})
registry.Register(&collectTool{sw: sw})
registry.Register(&scheduleTool{sw: sw})
}
// policyFrom derives the member-inheritance policy from the live tool options so
+1 -1
View File
@@ -30,7 +30,7 @@ func newToolSwarm(t *testing.T, l MemberLauncher) (*tools.Registry, *Swarm) {
func TestRegisterToolsRegistersAll(t *testing.T) {
reg, _ := newToolSwarm(t, newLauncher(okFor))
for _, name := range []string{SpawnToolName, SendToolName, InboxToolName, StatusToolName, HandoffToolName, CollectToolName, ScheduleToolName} {
for _, name := range []string{SpawnToolName, SendToolName, InboxToolName, StatusToolName, HandoffToolName, CollectToolName} {
if _, ok := reg.Get(name); !ok {
t.Fatalf("tool %q not registered", name)
}
+3 -8
View File
@@ -217,15 +217,10 @@ func CoreShellToolsScoped(workspaceRoot string, scope PathScope) []Tool {
}
func CoreNetworkTools() []Tool {
tools := []Tool{NewWebFetchTool()}
// Only offer the built-in web_search when a backend is actually configured.
// Registering it unconfigured makes the model waste calls (and high-risk
// permission prompts) on a tool that can only return "no backend configured";
// an MCP-provided search tool (e.g. Exa) stands on its own without it.
if defaultSearchBackend() != nil {
tools = append(tools, NewWebSearchTool())
return []Tool{
NewWebFetchTool(),
NewWebSearchTool(),
}
return tools
}
func CoreTools(workspaceRoot string) []Tool { return CoreToolsScoped(workspaceRoot, nil) }
-18
View File
@@ -47,8 +47,6 @@ func TestCoreReadOnlyToolsExposeSafeMetadata(t *testing.T) {
}
func TestCoreNetworkToolsExposePromptMetadata(t *testing.T) {
// web_search is only registered when a backend is configured.
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
byName := map[string]Tool{}
for _, tool := range CoreNetworkTools() {
byName[tool.Name()] = tool
@@ -78,22 +76,6 @@ func TestCoreNetworkToolsExposePromptMetadata(t *testing.T) {
}
}
func TestCoreNetworkToolsOmitWebSearchWhenUnconfigured(t *testing.T) {
// No backend configured → don't offer web_search (it could only error, which
// makes the model waste calls + prompts before falling back to an MCP search).
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "")
names := map[string]bool{}
for _, tool := range CoreNetworkTools() {
names[tool.Name()] = true
}
if !names["web_fetch"] {
t.Fatal("web_fetch should always be present")
}
if names["web_search"] {
t.Fatal("web_search must be omitted when no search backend is configured")
}
}
func TestCoreToolsIncludeWebFetchButReadOnlyToolsDoNot(t *testing.T) {
readOnly := CoreReadOnlyTools(t.TempDir())
for _, tool := range readOnly {
+11 -44
View File
@@ -6,11 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -162,7 +160,7 @@ func (tool webSearchTool) Run(ctx context.Context, args map[string]any) Result {
if len(results) > 0 && len(domains) > 0 {
filtered, err := filterWebSearchByDomains(results, domains)
if err != nil {
return errorResult("Error: " + redactWebSearchText(err.Error()))
return errorResult("Error: " + err.Error())
}
results = filtered
}
@@ -177,9 +175,9 @@ func (tool webSearchTool) Run(ctx context.Context, args map[string]any) Result {
// formatSearchResults renders results as a compact numbered list:
// "1. Title — URL" with the snippet indented on the next line.
// If a result carries a score that rounds to at least 0.01, it is shown as
// " — score 0.91" after the title. Absent (zero), negative, and sub-0.005 scores
// are omitted so the common case stays tidy and never prints a noisy "score 0.00".
// If a result carries a non-zero Score, it is shown as " — score 0.91" after
// the title; absence (zero value) is rendered without the score to keep the
// common case tidy.
func formatSearchResults(results []searchResult) string {
lines := make([]string, 0, len(results)*2)
for index, result := range results {
@@ -188,11 +186,8 @@ func formatSearchResults(results []searchResult) string {
title = "(untitled)"
}
header := fmt.Sprintf("%d. %s — %s", index+1, title, strings.TrimSpace(result.URL))
// Gate on the *rounded* value: a raw "> 0" check would still render
// "score 0.00" for tiny positives like 0.0001, the exact noise the
// score suffix is meant to avoid.
if rounded := math.Round(result.Score*100) / 100; rounded >= 0.01 {
header += fmt.Sprintf(" — score %.2f", rounded)
if result.Score > 0 {
header += fmt.Sprintf(" — score %.2f", result.Score)
}
lines = append(lines, header)
if snippet := strings.TrimSpace(result.Snippet); snippet != "" {
@@ -301,39 +296,16 @@ func (backend *httpSearchBackend) Search(ctx context.Context, query string, limi
return parseSearchResults(body)
}
// lenientScore decodes a provider "score" that may arrive as a JSON number, a
// numeric string ("0.91"), null, or some other shape, never failing the decode.
// A plain float64 field would make json.Unmarshal reject the *entire* response
// when a single provider sends a stringified or malformed score, dropping every
// result; here an unparseable score degrades to the zero value (treated as
// absent) instead.
type lenientScore float64
func (s *lenientScore) UnmarshalJSON(data []byte) error {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil
}
// Accept both 0.91 and "0.91"; ignore anything else (object, bool, NaN/Inf,
// out-of-range) by leaving the score at zero. ParseFloat accepts "NaN"/"Inf",
// so reject non-finite results explicitly to honor the documented filter.
unquoted := strings.TrimSpace(strings.Trim(string(trimmed), `"`))
if f, parseErr := strconv.ParseFloat(unquoted, 64); parseErr == nil && !math.IsNaN(f) && !math.IsInf(f, 0) {
*s = lenientScore(f)
}
return nil
}
// parseSearchResults accepts either a bare array [{title,url,snippet,score}] or
// a wrapped object {"results":[...]}, the two shapes common across providers.
// The optional "score" field is forwarded when present; providers that omit
// it leave searchResult.Score at the zero value and the renderer skips it.
func parseSearchResults(body []byte) ([]searchResult, error) {
type rawResult struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Score lenientScore `json:"score,omitempty"`
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Score float64 `json:"score,omitempty"`
}
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
@@ -347,7 +319,7 @@ func parseSearchResults(body []byte) ([]searchResult, error) {
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Score: float64(item.Score),
Score: item.Score,
})
}
return out
@@ -449,10 +421,6 @@ func canonicalizeWebSearchHost(raw string) string {
trimmed = trimmed[:i]
}
trimmed = strings.ToLower(trimmed)
// Drop a trailing FQDN dot ("react.dev." == "react.dev") so an allowlist
// entry and a result host normalize the same way; hostFromWebSearchURL does
// the same on the result side.
trimmed = strings.TrimSuffix(trimmed, ".")
trimmed = strings.TrimPrefix(trimmed, "www.")
if trimmed == "" || strings.ContainsAny(trimmed, " \t\n") {
return ""
@@ -493,7 +461,6 @@ func hostFromWebSearchURL(raw string) string {
return ""
}
host := strings.ToLower(parsed.Hostname())
host = strings.TrimSuffix(host, ".") // match canonicalizeWebSearchHost (FQDN dot)
host = strings.TrimPrefix(host, "www.")
return host
}
+1 -122
View File
@@ -103,8 +103,6 @@ func TestWebSearchRedactsBackendError(t *testing.T) {
}
func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
// web_search is registered only when a backend is configured.
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
found := false
for _, tool := range CoreNetworkTools() {
if tool.Name() == "web_search" {
@@ -112,7 +110,7 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
}
}
if !found {
t.Fatal("web_search should be registered in CoreNetworkTools() when a backend is configured")
t.Fatal("web_search should be registered in CoreNetworkTools()")
}
}
@@ -588,122 +586,3 @@ func TestCanonicalizeWebSearchHostStripsPort(t *testing.T) {
}
}
}
// Test added to verify mixed-type array handling
func TestStringListArgWebSearchRejectsMixedTypes(t *testing.T) {
args := map[string]any{
"domains": []any{"react.dev", 123, "github.com"},
}
domains, provided, err := stringListArgWebSearch(args, "domains")
if err == nil {
t.Fatal("Expected error for mixed-type array, but got nil")
}
if !provided {
t.Fatal("Expected provided=true since the argument was passed")
}
if err.Error() != "domains[1] must be a string" {
t.Fatalf("Expected 'domains[1] must be a string', got '%v'", err)
}
if domains != nil {
t.Fatalf("Expected nil domains on error, got %v", domains)
}
t.Logf("Test passed: got expected error: %v", err)
}
func TestWebSearchRejectsMixedTypeDomainsE2E(t *testing.T) {
// End-to-end test: web_search tool rejects mixed-type array
tool := newWebSearchToolWithBackend(&fakeSearchBackend{results: []searchResult{
{Title: "ok", URL: "https://react.dev/x"},
}})
res := tool.Run(context.Background(), map[string]any{
"query": "x",
"domains": []any{"react.dev", 123, "github.com"},
})
if res.Status != StatusError {
t.Fatalf("Expected error for mixed-type domains, got %v: %s", res.Status, res.Output)
}
if !strings.Contains(res.Output, "domains[1] must be a string") {
t.Fatalf("Expected 'domains[1] must be a string' in error message, got: %s", res.Output)
}
}
// ---- fix/web-search-209: robustness + rendering + normalization -------------
// #1: a stringified or malformed score must not discard the whole response.
func TestParseSearchResultsToleratesNonNumericScore(t *testing.T) {
body := []byte(`{"results":[
{"title":"num","url":"https://a.test","snippet":"s","score":0.77},
{"title":"str","url":"https://b.test","snippet":"s","score":"0.5"},
{"title":"bad","url":"https://c.test","snippet":"s","score":"high"},
{"title":"obj","url":"https://d.test","snippet":"s","score":{"x":1}},
{"title":"null","url":"https://e.test","snippet":"s","score":null},
{"title":"nan","url":"https://f.test","snippet":"s","score":"NaN"},
{"title":"inf","url":"https://g.test","snippet":"s","score":"Inf"}
]}`)
results, err := parseSearchResults(body)
if err != nil {
t.Fatalf("a single odd score must not fail the parse: %v", err)
}
if len(results) != 7 {
t.Fatalf("want 7 results (none dropped), got %d", len(results))
}
if results[0].Score != 0.77 {
t.Errorf("numeric score = %v, want 0.77", results[0].Score)
}
if results[1].Score != 0.5 {
t.Errorf("numeric-string score = %v, want 0.5", results[1].Score)
}
// Indices 5 and 6 cover ParseFloat-accepted non-finite values ("NaN"/"Inf"),
// which must be rejected so the documented filter holds and the renderer never
// emits a non-finite score.
for _, i := range []int{2, 3, 4, 5, 6} {
if results[i].Score != 0 {
t.Errorf("unparseable/null/non-finite score[%d] = %v, want 0 (absent)", i, results[i].Score)
}
}
}
// #2: tiny positive and negative scores must not render; >=0.005 rounds in.
func TestWebSearchScoreRenderingGate(t *testing.T) {
backend := &fakeSearchBackend{results: []searchResult{
{Title: "tiny", URL: "https://a.test/1", Score: 0.0001},
{Title: "neg", URL: "https://a.test/2", Score: -0.5},
{Title: "round-in", URL: "https://a.test/3", Score: 0.005},
{Title: "shown", URL: "https://a.test/4", Score: 0.91},
}}
res := newWebSearchToolWithBackend(backend).Run(context.Background(), map[string]any{"query": "x"})
if res.Status != StatusOK {
t.Fatalf("status = %v: %s", res.Status, res.Output)
}
if strings.Contains(res.Output, "score 0.00") {
t.Errorf("tiny/negative score must not render 'score 0.00', got: %s", res.Output)
}
if !strings.Contains(res.Output, "score 0.01") {
t.Errorf("0.005 should round to 'score 0.01', got: %s", res.Output)
}
if !strings.Contains(res.Output, "score 0.91") {
t.Errorf("0.91 should render, got: %s", res.Output)
}
}
// #4: trailing-dot FQDN entries/results normalize symmetrically.
func TestWebSearchDomainsFilterTrailingDotSymmetry(t *testing.T) {
// allowlist has the dot, result does not
res := newWebSearchToolWithBackend(&fakeSearchBackend{results: []searchResult{
{Title: "ok", URL: "https://react.dev/x"},
}}).Run(context.Background(), map[string]any{"query": "x", "domains": []any{"react.dev."}})
if res.Status != StatusOK {
t.Fatalf("allowlist 'react.dev.' should match 'react.dev' result, got %v: %s", res.Status, res.Output)
}
// result has the dot, allowlist does not
res = newWebSearchToolWithBackend(&fakeSearchBackend{results: []searchResult{
{Title: "ok", URL: "https://react.dev./x"},
}}).Run(context.Background(), map[string]any{"query": "x", "domains": []any{"react.dev"}})
if res.Status != StatusOK {
t.Fatalf("allowlist 'react.dev' should match 'react.dev.' result, got %v: %s", res.Status, res.Output)
}
}
+7 -7
View File
@@ -4,7 +4,7 @@ import (
"context"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
)
@@ -62,7 +62,7 @@ func TestAskUserPromptCollectsAnswersInOrder(t *testing.T) {
// Answer the first question.
next.input.SetValue("React")
updated, cmd := next.Update(testKey(tea.KeyEnter))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected first answer to advance synchronously")
@@ -76,7 +76,7 @@ func TestAskUserPromptCollectsAnswersInOrder(t *testing.T) {
// Answer the second (final) question.
next.input.SetValue("yes")
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected final answer to resolve synchronously")
@@ -109,7 +109,7 @@ func TestAskUserPromptEscDeliversCollectedAnswers(t *testing.T) {
// Esc while an ask_user prompt is active cancels the questionnaire and must
// still deliver a (partial/empty) answer set so the run never deadlocks.
updated, _ = next.Update(testKey(tea.KeyEsc))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEsc})
next = updated.(model)
if next.pendingAskUser != nil {
@@ -137,7 +137,7 @@ func TestAskUserPromptBlocksNormalSubmit(t *testing.T) {
next := updated.(model)
next.input.SetValue("/help")
updated, _ = next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if transcriptContains(next.transcript, "Available commands") {
@@ -174,7 +174,7 @@ func TestAskUserRequestClearsComposerDraft(t *testing.T) {
t.Fatalf("ask_user should clear composer draft, active=%v value=%q", next.composerActive, next.composerValue())
}
next.input.SetValue("yes")
updated, _ = next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if len(answers) != 1 || answers[0][0] != "yes" {
t.Fatalf("expected answer to use ask_user input only, got %#v", answers)
@@ -205,7 +205,7 @@ func TestAskUserRequestClearsStaleSuggestions(t *testing.T) {
if len(next.suggestions) != 0 || next.suggestionsAreFiles {
t.Fatalf("ask_user should clear stale suggestions, got suggestions=%#v files=%v", next.suggestions, next.suggestionsAreFiles)
}
updated, _ = next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if len(next.suggestions) != 0 || next.suggestionsAreFiles {
t.Fatalf("ask_user resolve should keep suggestions clear, got suggestions=%#v files=%v", next.suggestions, next.suggestionsAreFiles)
+5 -1
View File
@@ -5,7 +5,7 @@ import (
"unicode"
"unicode/utf8"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss"
)
const (
@@ -666,6 +666,10 @@ func renderMarkdownStandaloneLine(line string) string {
return strings.TrimRight(line, " ")
}
func stripMarkdownInline(text string) string {
return markdownInlinePlain(text)
}
type markdownInlineSegment struct {
text string
bold bool
+10
View File
@@ -333,6 +333,16 @@ func completePathQueryWithTrailingSpace(value string, cursorPos int, selectedPat
return string(out), query.StartIndex + len(replacement)
}
func removeTrailingAtToken(value string) string {
if _, ok := trailingAtToken(value); !ok {
return value
}
if i := strings.LastIndexAny(value, " \t\n"); i >= 0 {
return value[:i+1]
}
return ""
}
// maxFileWalk bounds how many filesystem entries the "@file" picker visits per
// keystroke so a large workspace tree can't stall the TUI.
const maxFileWalk = 4000
+37 -37
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
)
@@ -17,7 +17,7 @@ import (
func typeRunes(t *testing.T, m model, s string) model {
t.Helper()
for _, r := range s {
updated, _ := m.Update(testKeyText(string(r)))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = updated.(model)
}
return m
@@ -83,7 +83,7 @@ func TestTabCyclesSuggestions(t *testing.T) {
m = typeRunes(t, m, "/mo")
start := m.suggestionIdx
updated, _ := m.Update(testKey(tea.KeyTab))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(model)
if m.suggestionIdx == start {
t.Fatal("Tab should advance the selected suggestion")
@@ -91,7 +91,7 @@ func TestTabCyclesSuggestions(t *testing.T) {
// Tab past the end wraps to 0.
for i := 0; i < len(m.suggestions); i++ {
updated, _ = m.Update(testKey(tea.KeyTab))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(model)
}
if m.suggestionIdx != m.suggestionIdx%len(m.suggestions) {
@@ -103,15 +103,15 @@ func TestUpDownMoveSuggestions(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/mo")
updated, _ := m.Update(testKey(tea.KeyDown))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(model)
if m.suggestionIdx != 1 {
t.Fatalf("Down should select index 1, got %d", m.suggestionIdx)
}
// Up from index 0 wraps to the last suggestion.
updated, _ = m.Update(testKey(tea.KeyUp))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyUp))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(model)
if m.suggestionIdx != len(m.suggestions)-1 {
t.Fatalf("Up past the top should wrap to last (%d), got %d", len(m.suggestions)-1, m.suggestionIdx)
@@ -122,13 +122,13 @@ func TestMouseWheelMovesSuggestions(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/")
updated, _ := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0))
updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown})
m = updated.(model)
if m.suggestionIdx != 1 {
t.Fatalf("wheel down should select index 1, got %d", m.suggestionIdx)
}
updated, _ = m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0))
updated, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp})
m = updated.(model)
if m.suggestionIdx != 0 {
t.Fatalf("wheel up should select index 0, got %d", m.suggestionIdx)
@@ -139,7 +139,7 @@ func TestEnterRunsCommandSuggestion(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/he") // selects /help
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -160,7 +160,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/sp") // selects /spec
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -173,7 +173,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) {
t.Fatalf("prefilled command should show argument hint, got %q", got)
}
updated, cmd = m.Update(testKeyText("fix"))
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("fix")})
m = updated.(model)
if cmd != nil {
t.Fatal("typing the argument should not start an agent run")
@@ -182,7 +182,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) {
t.Fatalf("typing after the hint should insert one argument separator, got %q", got)
}
for range "fix" {
updated, cmd = m.Update(testKey(tea.KeyBackspace))
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(model)
if cmd != nil {
t.Fatal("backspacing the argument should not start an agent run")
@@ -191,7 +191,7 @@ func TestEnterPrefillsCommandSuggestionRequiringInput(t *testing.T) {
if got := plainRender(t, m.composerLine(96)); !strings.Contains(got, "/spec [task]") || strings.Contains(got, "/spec [task]") {
t.Fatalf("empty argument command should render one visual separator, got %q", got)
}
updated, cmd = m.Update(testKeyText("x"))
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
m = updated.(model)
if cmd != nil {
t.Fatal("typing after deleting the argument should not start an agent run")
@@ -227,7 +227,7 @@ func TestTabCompletesAfterSelection(t *testing.T) {
// Move to /mode, then Tab again -> per spec Tab cycles, so we use Down then
// Enter to lock the selection; verify Tab keeps cycling not completing.
updated, _ := m.Update(testKey(tea.KeyTab))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(model)
if m.input.Value() != "/mo" {
t.Fatalf("Tab should cycle, not yet complete; input=%q", m.input.Value())
@@ -238,7 +238,7 @@ func TestEscDismissesCommandSuggestionsAndClearsInput(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/mo")
updated, _ := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
if m.suggestionsActive() {
@@ -253,7 +253,7 @@ func TestEscWithoutSuggestionsClearsInputAsBefore(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "hello") // no suggestions
updated, _ := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
if m.input.Value() != "" {
t.Fatalf("Esc with no suggestions should clear input, got %q", m.input.Value())
@@ -264,7 +264,7 @@ func TestEnterWithNoSuggestionStillSubmits(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("hello zero") // plain prompt, no suggestions
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if next.input.Value() != "" {
@@ -399,7 +399,7 @@ func TestSuggestionOverlayCapsRowsWithoutMoreText(t *testing.T) {
}
for range suggestionPaletteMaxVisible + 1 {
updated, _ := m.Update(testKey(tea.KeyDown))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(model)
}
plain = plainRender(t, m.View())
@@ -438,7 +438,7 @@ func TestEnterOnNoMatchCommandPaletteDoesNotSubmit(t *testing.T) {
m := newModel(context.Background(), Options{})
m = typeRunes(t, m, "/,")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -481,7 +481,7 @@ func TestEscDismissesFilePaletteAndRemovesTrailingToken(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
m = typeRunes(t, m, "read @missing")
updated, _ := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
if m.suggestionsActive() {
@@ -501,7 +501,7 @@ func TestEscDismissesFilePaletteAndRemovesTrailingToken(t *testing.T) {
m.input.SetCursor(len([]rune("compare @old")))
m.recomputeSuggestions()
updated, _ = m.Update(testKey(tea.KeyEsc))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
if m.suggestionsActive() {
@@ -589,7 +589,7 @@ func TestEnterOnDirectorySuggestionKeepsFilePaletteOpen(t *testing.T) {
t.Fatalf("expected internal directory first, got %q from %v", got, suggestionNames(m))
}
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -616,7 +616,7 @@ func TestEnterOnFileSuggestionClosesFilePalette(t *testing.T) {
t.Fatalf("expected file suggestion first, got %q from %v", got, suggestionNames(m))
}
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -637,17 +637,17 @@ func TestFileSuggestionCompletionKeepsPastePreviewCollapsed(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: root})
m.width = 44
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeySpace))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(model)
updated, _ = m.Update(testKeyText("@guide"))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
m = updated.(model)
if !m.suggestionsActive() || !m.suggestionsAreFiles {
t.Fatalf("expected file suggestions after @guide, got suggestions=%v files=%v", m.suggestionsActive(), m.suggestionsAreFiles)
}
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if cmd != nil {
@@ -674,17 +674,17 @@ func TestFileSuggestionDismissKeepsPastePreviewCollapsed(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: root})
m.width = 44
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeySpace))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(model)
updated, _ = m.Update(testKeyText("@guide"))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
m = updated.(model)
if !m.suggestionsActive() || !m.suggestionsAreFiles {
t.Fatalf("expected file suggestions after @guide, got suggestions=%v files=%v", m.suggestionsActive(), m.suggestionsAreFiles)
}
updated, _ = m.Update(testKey(tea.KeyEsc))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
if got := m.composerValue(); got != paste+" " {
@@ -711,19 +711,19 @@ func TestBackspaceAfterCompletedFileSuggestionKeepsPastePreviewCollapsed(t *test
m := newModel(context.Background(), Options{Cwd: root})
m.width = 44
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeySpace))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(model)
updated, _ = m.Update(testKeyText("@guide"))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyEnter))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
if got := m.composerValue(); got != paste+" @docs/guide.md " {
t.Fatalf("composer value after file completion = %q", got)
}
updated, _ = m.Update(testKey(tea.KeyBackspace))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(model)
if got := m.composerValue(); got != paste+" " {
-55
View File
@@ -1,55 +0,0 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
)
// clipboardReadMsg carries the result of an async OS-clipboard read back to
// Update. It drives the right-click "paste" path.
type clipboardReadMsg struct {
content string
err error
}
// pasteFromClipboardCmd reads the OS clipboard off the Update goroutine (it
// shells out to pbpaste/xclip/etc.) and delivers the text as a clipboardReadMsg.
// A right-click pastes straight from here — no menu.
func pasteFromClipboardCmd() tea.Cmd {
return func() tea.Msg {
content, err := clipboard.ReadAll()
return clipboardReadMsg{content: content, err: err}
}
}
// routePaste inserts pasted text into whichever input surface is focused. It is
// shared by the terminal bracketed-paste handler (tea.PasteMsg) and the
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
// paste behave identically. Surfaces with no editable text field (a permission/
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
if content == "" {
return m, nil
}
// Setup and the ask_user questionnaire share the main text input.
if m.setup.visible {
return m.handleSetupPaste(tea.PasteMsg{Content: content})
}
if m.pendingAskUser != nil {
var cmd tea.Cmd
m.input, cmd = m.input.Update(tea.PasteMsg{Content: content})
return m, cmd
}
if m.providerWizard != nil {
return m.handleProviderWizardPaste(content)
}
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil {
return m, nil
}
state := m.currentComposerState()
m = m.applyComposerText(state, content, true)
m.recomputeSuggestions()
return m, nil
}
-78
View File
@@ -1,78 +0,0 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
func TestMouseRightPressDetectsRightClick(t *testing.T) {
if !mouseRightPress(testMouseClick(tea.MouseRight, 10, 5)) {
t.Fatal("a right-click should be detected as a right press")
}
if mouseRightPress(testMouseClick(tea.MouseLeft, 10, 5)) {
t.Fatal("a left-click must not register as a right press")
}
}
func TestRightClickReturnsClipboardReadCmd(t *testing.T) {
// A right-click pastes the clipboard directly (no menu): it returns the async
// clipboard-read command. Assert at the handleMouse/handleSetupMouse scope
// where the contract actually lives — not via Update, which batches unrelated
// commands (ticks, etc.) so a nil-check there passes even if the right-click
// branch regresses.
m := mouseTestModel()
if _, cmd := m.handleMouse(testMouseClick(tea.MouseRight, 40, 10)); cmd == nil {
t.Fatal("a right-click in the chat should return the clipboard-read command")
}
// Provider wizard — also routed through handleMouse.
w := mouseTestModel()
w.providerWizard = &providerWizardState{step: providerWizardStepCredential}
if _, cmd := w.handleMouse(testMouseClick(tea.MouseRight, 40, 10)); cmd == nil {
t.Fatal("a right-click in the wizard should return the clipboard-read command")
}
// Setup mode is routed to handleSetupMouse, which has its own right-click
// branch; without it setup would swallow the paste.
s := mouseTestModel()
if _, cmd := s.handleSetupMouse(testMouseClick(tea.MouseRight, 40, 10)); cmd == nil {
t.Fatal("a right-click in setup should return the clipboard-read command")
}
}
func TestClipboardReadRoutesToFocusedField(t *testing.T) {
// Provider wizard API-key field.
m := newModel(context.Background(), Options{})
m.providerWizard = &providerWizardState{step: providerWizardStepCredential}
updated, _ := m.Update(clipboardReadMsg{content: "sk-from-clipboard"})
if next := updated.(model); next.providerWizard.apiKey != "sk-from-clipboard" {
t.Fatalf("clipboard paste should fill the API-key field, got %q", next.providerWizard.apiKey)
}
// Composer when no modal field is focused.
m2 := newModel(context.Background(), Options{})
updated2, _ := m2.Update(clipboardReadMsg{content: "hello world"})
if next := updated2.(model); !strings.Contains(next.input.Value(), "hello world") {
t.Fatalf("clipboard paste should fill the composer, got %q", next.input.Value())
}
}
func TestClipboardReadErrorShowsStatus(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, _ := m.Update(clipboardReadMsg{err: errors.New("no clipboard utility")})
if next := updated.(model); next.copyStatus != "Paste failed" {
t.Fatalf("a clipboard-read error should surface a status, got %q", next.copyStatus)
}
}
func TestClipboardReadEmptyIsNoop(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, _ := m.Update(clipboardReadMsg{content: ""})
if next := updated.(model); next.input.Value() != "" {
t.Fatalf("an empty clipboard should be a silent no-op, got %q", next.input.Value())
}
}
-78
View File
@@ -1,78 +0,0 @@
package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestToolResultCollapsesLongOutputByDefault(t *testing.T) {
m := transcriptViewTestModel()
long := numberedLines(cardBodyMaxLines + 10)
rc := buildRowContext(nil)
row := transcriptRow{kind: rowToolResult, id: "t1", tool: "mcp_exa_web_search_exa", status: tools.StatusOK, detail: long}
collapsed := plainRender(t, m.renderRow(row, m.width, rc))
if strings.Contains(collapsed, "line-005") {
t.Errorf("collapsed card must hide the body, got:\n%s", collapsed)
}
if !strings.Contains(collapsed, "click to expand") {
t.Errorf("collapsed card must show the expand hint, got:\n%s", collapsed)
}
row.expanded = true
expanded := plainRender(t, m.renderRow(row, m.width, rc))
if !strings.Contains(expanded, "line-005") {
t.Errorf("expanded card must show the body, got:\n%s", expanded)
}
}
func TestToolResultShortOutputStaysInline(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{kind: rowToolResult, id: "t2", tool: "mcp_exa_web_search_exa", status: tools.StatusOK, detail: numberedLines(3)}
out := plainRender(t, m.renderRow(row, m.width, buildRowContext(nil)))
if strings.Contains(out, "click to expand") {
t.Errorf("short output must not collapse, got:\n%s", out)
}
if !strings.Contains(out, "line-003") {
t.Errorf("short output must render inline, got:\n%s", out)
}
}
func TestDiffToolOutputNeverCollapses(t *testing.T) {
m := transcriptViewTestModel()
long := numberedLines(cardBodyMaxLines + 10)
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
row := transcriptRow{kind: rowToolResult, id: "d", tool: tool, status: tools.StatusOK, detail: long}
out := plainRender(t, m.renderRow(row, m.width, buildRowContext(nil)))
if strings.Contains(out, "click to expand") {
t.Errorf("%s output must stay reviewable, not collapse:\n%s", tool, out)
}
}
}
func TestToggleTranscriptRowTogglesToolResult(t *testing.T) {
m := transcriptViewTestModel()
m.transcript = []transcriptRow{{kind: rowToolResult, id: "t", tool: "custom_tool"}}
if m.transcript[0].expanded {
t.Fatal("tool result must default to collapsed")
}
m = m.toggleTranscriptRow(0)
if !m.transcript[0].expanded {
t.Fatal("toggle should expand the tool result")
}
m = m.toggleTranscriptRow(0)
if m.transcript[0].expanded {
t.Fatal("toggle should collapse the tool result again")
}
}
func TestToolResultRowExposesClickToggle(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{kind: rowToolResult, id: "t", tool: "custom_tool", status: tools.StatusOK, detail: numberedLines(cardBodyMaxLines + 5)}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) != 1 || !selectable[0].toggle {
t.Fatalf("tool result head must be a clickable toggle line, got %#v", selectable)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
)
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
+15 -37
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
@@ -229,20 +229,17 @@ func (m model) searchText(query string) string {
return zsearch.FormatResult(zsearch.RedactResult(result))
}
// resumeText is the text fallback for /resume — the no-session "none" message and
// the store-error message (the interactive picker handles the populated case). It
// also still backs the stacked-card list rendering as a defensive fallback.
func (m model) resumeText() string {
// Defensive: a model without a session store (some fallback/test paths) must
// render a safe message rather than panic on the nil dereference below.
if m.sessionStore == nil {
func (m model) resumeText(args string) string {
args = strings.TrimSpace(args)
if args != "" {
return renderCommandOutput(commandOutput{
Title: "Sessions",
Status: commandStatusBlocked,
Status: commandStatusInfo,
Sections: []commandSection{{
Title: "Store",
Lines: []string{"session store unavailable"},
Title: "Resume",
Lines: []string{"requested session: " + args},
}},
Hints: []string{"use /resume " + args + " to hydrate this TUI session"},
})
}
// Only standalone conversations — not child/spec sub-runs, which an agent
@@ -346,6 +343,12 @@ func (defaultModelSwitchCompactionPolicy) BeforeModelSwitch(request modelSwitchC
}
}
type noopModelSwitchCompactionPolicy struct{}
func (noopModelSwitchCompactionPolicy) BeforeModelSwitch(modelSwitchCompactionRequest) modelSwitchCompactionDecision {
return modelSwitchCompactionDecision{}
}
var modelSwitchCompactionGuard modelSwitchCompactionPolicy = defaultModelSwitchCompactionPolicy{}
// sanitizeCardField strips the card protocol's separator bytes from
@@ -427,7 +430,6 @@ func (m model) handleModelCommand(args string) (model, string) {
if err != nil {
return m, "Model\n" + err.Error()
}
persisted, persistErr := m.persistSelectedModel(nextProfile)
m.providerProfile = nextProfile
m.provider = nextProvider
@@ -455,39 +457,15 @@ func (m model) handleModelCommand(args string) (model, string) {
lines = append(lines, target.notice)
}
lines = append(lines,
"Switched model.",
"Switched model for this TUI session.",
"model: "+target.modelID,
"provider: "+displayValue(nextProfile.Name, string(metadata.ProviderKind)),
"api model: "+metadata.APIModel,
effortLine,
)
if persisted {
lines = append(lines, "saved: user config")
} else if persistErr != nil {
lines = append(lines, "saved: no ("+persistErr.Error()+")")
}
return m, strings.Join(lines, "\n")
}
func (m model) persistSelectedModel(profile config.ProviderProfile) (bool, error) {
path := strings.TrimSpace(m.userConfigPath)
if path == "" {
return false, nil
}
name := strings.TrimSpace(profile.Name)
if name == "" {
return false, nil
}
model := strings.TrimSpace(profile.Model)
if model == "" {
return false, nil
}
if _, err := config.SetProviderModel(path, name, model); err != nil {
return false, err
}
return true, nil
}
type modelSwitchTarget struct {
modelID string
entry *modelregistry.ModelEntry
+11 -11
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
@@ -19,7 +19,7 @@ func TestHelpCommandRendersGroupedSections(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/help")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -57,7 +57,7 @@ func TestProviderAndConfigCommandsUseStableStatusOutput(t *testing.T) {
})
m.input.SetValue("/provider status")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /provider to be handled without starting an agent run")
@@ -69,7 +69,7 @@ func TestProviderAndConfigCommandsUseStableStatusOutput(t *testing.T) {
assertNotContains(t, providerText, "sk-sensitive")
next.input.SetValue("/config")
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected /config to be handled without starting an agent run")
@@ -95,7 +95,7 @@ func TestProviderCommandRedactsCredentialBearingBaseURL(t *testing.T) {
})
m.input.SetValue("/provider status")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -114,7 +114,7 @@ func TestToolsCommandRendersCommandCard(t *testing.T) {
})
m.input.SetValue("/tools")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -140,7 +140,7 @@ func TestToolsCommandRendersCommandCard(t *testing.T) {
})
m.input.SetValue("/tools")
updated, cmd = m.Update(testKey(tea.KeyEnter))
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
@@ -195,7 +195,7 @@ func TestToolsCommandShowsFullSortedCatalog(t *testing.T) {
})
m.input.SetValue("/tools")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -268,7 +268,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) {
})
m.input.SetValue("/context")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /context to be handled without starting an agent run")
@@ -294,7 +294,7 @@ func TestContextAndPermissionsCommandsRenderProductState(t *testing.T) {
assertNotContains(t, contextText, "permission mode:")
next.input.SetValue("/permissions")
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected /permissions to be handled without starting an agent run")
@@ -388,7 +388,7 @@ func TestCompactCommandAvoidsShellOnlyPlaceholder(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/compact")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
-8
View File
@@ -25,7 +25,6 @@ const (
commandPlan
commandSearch
commandResume
commandRetitle
commandSpec
commandCompact
commandRewind
@@ -161,13 +160,6 @@ var commandDefinitions = []commandDefinition{
description: "List recent sessions or show resume guidance.",
kind: commandResume,
},
{
name: "/retitle",
usage: "/retitle",
group: commandGroupSession,
description: "Generate concise titles for resumable sessions.",
kind: commandRetitle,
},
{
name: "/spec",
usage: "/spec <task>",
+46 -46
View File
@@ -8,7 +8,7 @@ import (
"testing"
"time"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
@@ -176,7 +176,7 @@ func TestMCPCommandRendersConfiguredStateWithoutAgentRun(t *testing.T) {
m.height = 42
m.input.SetValue("/mcp")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -230,7 +230,7 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/mcp")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /mcp to open synchronously")
@@ -239,13 +239,13 @@ func TestMCPManagerNavigationOpensAddWizard(t *testing.T) {
t.Fatal("expected MCP manager to open")
}
updated, cmd = next.Update(testKeyText("custom remote"))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("custom remote")})
next = updated.(model)
if cmd != nil {
t.Fatal("expected MCP manager search to update synchronously")
}
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected MCP manager selection to prefill synchronously")
@@ -267,9 +267,9 @@ func TestMCPManagerSearchFiltersMarketplace(t *testing.T) {
m.height = 36
m.input.SetValue("/mcp")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
updated, cmd := next.Update(testKeyText("playwright"))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("playwright")})
next = updated.(model)
if cmd != nil {
t.Fatal("expected search typing to be synchronous")
@@ -295,11 +295,11 @@ func TestMCPManagerDeleteEditsSearchQuery(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/mcp")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
updated, _ = next.Update(testKeyText("play"))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("play")})
next = updated.(model)
updated, cmd := next.Update(testKey(tea.KeyDelete))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyDelete})
next = updated.(model)
if cmd != nil {
@@ -314,11 +314,11 @@ func TestMCPManagerMarketplaceSelectionPrefillsInstallCommand(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/mcp")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
updated, _ = next.Update(testKeyText("context7"))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("context7")})
next = updated.(model)
updated, cmd := next.Update(testKey(tea.KeyEnter))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected marketplace selection to prefill synchronously")
@@ -337,9 +337,9 @@ func TestMCPManagerAddRemoteOpensWizard(t *testing.T) {
m.height = 36
m.input.SetValue("/mcp")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
updated, cmd := next.Update(testKeyAltText("a"))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Alt: true})
next = updated.(model)
if cmd != nil {
@@ -375,12 +375,12 @@ func TestMCPAddWizardInvalidURLShowsUnsavedResult(t *testing.T) {
m.height = 36
m.mcpAddWizard = newMCPAddWizard("http")
for _, key := range []tea.Msg{
testKeyText("adds"),
testKey(tea.KeyEnter),
testKey(tea.KeyEnter),
testKeyText("sxas"),
testKey(tea.KeyEnter),
for _, key := range []tea.KeyMsg{
{Type: tea.KeyRunes, Runes: []rune("adds")},
{Type: tea.KeyEnter},
{Type: tea.KeyEnter},
{Type: tea.KeyRunes, Runes: []rune("sxas")},
{Type: tea.KeyEnter},
} {
updated, cmd := m.Update(key)
if cmd != nil {
@@ -431,13 +431,13 @@ func TestMCPAddWizardInvalidURLCanSaveDisabledDraft(t *testing.T) {
m.mcpAddWizard = newMCPAddWizard("http")
var cmd tea.Cmd
for index, key := range []tea.Msg{
testKeyText("draft"),
testKey(tea.KeyEnter),
testKey(tea.KeyEnter),
testKeyText("sxas"),
testKey(tea.KeyEnter),
testKeyText("s"),
for index, key := range []tea.KeyMsg{
{Type: tea.KeyRunes, Runes: []rune("draft")},
{Type: tea.KeyEnter},
{Type: tea.KeyEnter},
{Type: tea.KeyRunes, Runes: []rune("sxas")},
{Type: tea.KeyEnter},
{Type: tea.KeyRunes, Runes: []rune("s")},
} {
updated, nextCmd := m.Update(key)
if nextCmd != nil && index != 5 {
@@ -485,15 +485,15 @@ func TestMCPAddWizardSavesRemoteWithPastedHeader(t *testing.T) {
m.mcpAddWizard = newMCPAddWizard("http")
var cmd tea.Cmd
for index, key := range []tea.Msg{
testKeyText("docs"),
testKey(tea.KeyEnter),
testKey(tea.KeyEnter),
testKeyText("https://docs.example/mcp"),
testKey(tea.KeyEnter),
testKeyText("Authorization: Bearer secret"),
testKey(tea.KeyEnter),
testKey(tea.KeyEnter),
for index, key := range []tea.KeyMsg{
{Type: tea.KeyRunes, Runes: []rune("docs")},
{Type: tea.KeyEnter},
{Type: tea.KeyEnter},
{Type: tea.KeyRunes, Runes: []rune("https://docs.example/mcp")},
{Type: tea.KeyEnter},
{Type: tea.KeyRunes, Runes: []rune("Authorization: Bearer secret")},
{Type: tea.KeyEnter},
{Type: tea.KeyEnter},
} {
updated, nextCmd := m.Update(key)
if nextCmd != nil && index != 7 {
@@ -537,7 +537,7 @@ func TestChatMCPSetupStitchURLOpensPrefilledWizard(t *testing.T) {
m.height = 36
m.input.SetValue("configure this MCP https://stitch.withgoogle.com/docs/mcp/setup")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -582,7 +582,7 @@ func TestChatMCPSetupFalsePositiveSendsPrompt(t *testing.T) {
prompt := "how do I add a fetch call to my mcp client?"
m.input.SetValue(prompt)
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if next.mcpAddWizard != nil {
@@ -693,13 +693,13 @@ func TestChatMCPSetupStitchConfirmSavesServer(t *testing.T) {
m.height = 36
m.input.SetValue("setup stitch mcp from https://stitch.withgoogle.com/docs/mcp/setup")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected MCP setup intent to open synchronously")
}
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd == nil {
t.Fatal("expected MCP setup save to run asynchronously")
@@ -741,13 +741,13 @@ func TestMCPManagerRunsSelectedServerAction(t *testing.T) {
})
m.input.SetValue("/mcp")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if next.mcpManager == nil {
t.Fatal("expected MCP manager to open")
}
updated, cmd := next.Update(testKeyAltText("d"))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d"), Alt: true})
next = updated.(model)
if cmd == nil {
t.Fatal("expected MCP action to run asynchronously")
@@ -797,7 +797,7 @@ func TestMCPCommandRunsManagerActionAndRefreshesState(t *testing.T) {
})
m.input.SetValue("/mcp disable docs")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
@@ -836,7 +836,7 @@ func TestMCPCommandPreservesQuotedArguments(t *testing.T) {
})
m.input.SetValue(`/mcp add docs -- "C:\Program Files\docs mcp.exe" --label "Zero Docs"`)
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
@@ -867,7 +867,7 @@ func TestMCPCommandDoesNotApplyFailedConfig(t *testing.T) {
})
m.input.SetValue("/mcp disable docs")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
+56 -41
View File
@@ -7,8 +7,8 @@ import (
"unicode"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type composerState struct {
@@ -72,6 +72,16 @@ func deleteComposerWordAfter(state composerState) composerState {
return deleteComposerRange(state, state.cursor, end)
}
func deleteComposerLineBefore(state composerState) composerState {
state = normalizeComposerState(state)
return deleteComposerRange(state, composerLineStart(state), state.cursor)
}
func deleteComposerLineAfter(state composerState) composerState {
state = normalizeComposerState(state)
return deleteComposerRange(state, state.cursor, composerLineEnd(state))
}
func moveComposerWordBefore(state composerState) composerState {
state = normalizeComposerState(state)
runes := []rune(state.text)
@@ -178,70 +188,84 @@ func composerDisplayCursor(state composerState) int {
func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
state := m.currentComposerState()
switch {
case keyIs(msg, tea.KeyEnter) && keyAlt(msg):
case msg.Type == tea.KeyEnter && msg.Alt:
m = m.insertComposerTextWithPastePreview(state, "\n", "")
case keyCtrl(msg, 'j'):
case msg.Type == tea.KeyCtrlJ:
m = m.insertComposerTextWithPastePreview(state, "\n", "")
case keyAlt(msg) && keyText(msg) == "d":
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "d":
end := deleteComposerWordAfter(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyAlt(msg) && keyIs(msg, tea.KeyLeft):
case (msg.Type == tea.KeyLeft || msg.Type == tea.KeyCtrlLeft) && msg.Alt:
m.setComposerState(moveComposerWordBefore(state))
case keyAlt(msg) && keyIs(msg, tea.KeyRight):
case (msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlRight) && msg.Alt:
m.setComposerState(moveComposerWordAfter(state))
case keyCtrlArrow(msg, tea.KeyLeft):
case msg.Type == tea.KeyCtrlLeft:
m.setComposerState(moveComposerWordBefore(state))
case keyCtrlArrow(msg, tea.KeyRight):
case msg.Type == tea.KeyCtrlRight:
m.setComposerState(moveComposerWordAfter(state))
case keyAlt(msg) && keyText(msg) == "b":
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "b":
m.setComposerState(moveComposerWordBefore(state))
case keyAlt(msg) && keyText(msg) == "f":
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "f":
m.setComposerState(moveComposerWordAfter(state))
case keyIs(msg, tea.KeySpace):
case msg.Type == tea.KeySpace:
m = m.insertComposerTextWithPastePreview(state, " ", "")
case keyPrintable(msg):
m = m.applyComposerText(state, keyText(msg), false)
case keyIs(msg, tea.KeyLeft) || keyCtrl(msg, 'b'):
case msg.Type == tea.KeyRunes && !msg.Alt:
text := string(msg.Runes)
previewLabel := ""
if msg.Paste {
text = sanitizeComposerPaste(text)
previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth())
} else {
text = sanitizeComposerInput(text)
}
if shouldInsertCommandArgumentSpace(state, text) {
text = " " + text
if previewLabel != "" {
previewLabel = " " + previewLabel
}
}
m = m.insertComposerTextWithPastePreview(state, text, previewLabel)
case msg.Type == tea.KeyLeft || msg.Type == tea.KeyCtrlB:
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, -1); ok {
m.setComposerState(nextState)
break
}
state.cursor--
m.setComposerState(state)
case keyIs(msg, tea.KeyRight) || keyCtrl(msg, 'f'):
case msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlF:
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, 1); ok {
m.setComposerState(nextState)
break
}
state.cursor++
m.setComposerState(state)
case keyIs(msg, tea.KeyHome) || keyCtrl(msg, 'a'):
case msg.Type == tea.KeyHome || msg.Type == tea.KeyCtrlA:
state.cursor = composerLineStart(state)
m.setComposerState(state)
case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'):
case msg.Type == tea.KeyEnd || msg.Type == tea.KeyCtrlE:
state.cursor = composerLineEnd(state)
m.setComposerState(state)
case keyCtrl(msg, 'u'):
case msg.Type == tea.KeyCtrlU:
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, composerLineStart(state), state.cursor)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyCtrl(msg, 'k'):
case msg.Type == tea.KeyCtrlK:
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, composerLineEnd(state))
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyCtrl(msg, 'w') || (keyAlt(msg) && keyBackspace(msg)):
case msg.Type == tea.KeyCtrlW || (msg.Alt && (msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH)):
start := deleteComposerWordBefore(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, state.cursor)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyAlt(msg) && keyIs(msg, tea.KeyDelete):
case msg.Alt && msg.Type == tea.KeyDelete:
end := deleteComposerWordAfter(state).cursor
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
case keyBackspace(msg):
case msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH:
if nextState, nextPreviews, ok := deleteComposerPastePreviewBefore(state, m.composerPastePreviews); ok && !m.suggestionsActive() {
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
@@ -254,7 +278,7 @@ func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
}
case keyIs(msg, tea.KeyDelete) || keyCtrl(msg, 'd'):
case msg.Type == tea.KeyDelete || msg.Type == tea.KeyCtrlD:
if nextState, nextPreviews, ok := deleteComposerPastePreviewAfter(state, m.composerPastePreviews); ok {
m.setComposerState(nextState)
m.composerPastePreviews = nextPreviews
@@ -275,23 +299,6 @@ func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
return m, true
}
func (m model) applyComposerText(state composerState, text string, paste bool) model {
previewLabel := ""
if paste {
text = sanitizeComposerPaste(text)
previewLabel, _ = composerPastePreviewLabel(text, m.composerPastePreviewWrapWidth())
} else {
text = sanitizeComposerInput(text)
}
if shouldInsertCommandArgumentSpace(state, text) {
text = " " + text
if previewLabel != "" {
previewLabel = " " + previewLabel
}
}
return m.insertComposerTextWithPastePreview(state, text, previewLabel)
}
func (m model) insertComposerTextWithPastePreview(state composerState, text string, previewLabel string) model {
state = normalizeComposerState(state)
insertStart := state.cursor
@@ -573,6 +580,14 @@ func shouldInsertCommandArgumentSpace(state composerState, text string) bool {
return commandArgumentHintForInput(state.text) != ""
}
func deleteCompletedFileMentionBefore(state composerState) (composerState, bool) {
start, end, ok := completedFileMentionRangeBefore(state)
if !ok {
return state, false
}
return deleteComposerRange(state, start, end), true
}
func completedFileMentionRangeBefore(state composerState) (int, int, bool) {
state = normalizeComposerState(state)
runes := []rune(state.text)
+31 -31
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
)
func TestComposerInsertNewlineAtCursor(t *testing.T) {
@@ -87,7 +87,7 @@ func TestBackspaceAfterCompletedFileMentionRemovesWholeMention(t *testing.T) {
m.input.SetValue(tc.start)
m.input.CursorEnd()
updated, _ := m.Update(testKey(tea.KeyBackspace))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
next := updated.(model)
if got := next.composerValue(); got != tc.want {
@@ -109,7 +109,7 @@ func TestBackspaceInsideActiveFileMentionStillEditsQuery(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: root})
m = typeRunes(t, m, "@docs")
updated, _ := m.Update(testKey(tea.KeyBackspace))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
next := updated.(model)
if got := next.composerValue(); got != "@doc" {
@@ -134,7 +134,7 @@ func TestCtrlVDoesNotPasteIntoComposer(t *testing.T) {
m.input.SetValue("hello")
m.input.CursorEnd()
updated, cmd := m.Update(testKeyCtrl('v'))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlV})
next := updated.(model)
if cmd != nil {
@@ -154,7 +154,7 @@ func TestPastedMultilineComposerContentRendersAsPreview(t *testing.T) {
}, "\n")
m := newModel(context.Background(), Options{})
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
next := updated.(model)
if got := next.composerValue(); got != paste {
@@ -176,7 +176,7 @@ func TestPastedLongSingleLineComposerContentRendersWrappedLineCount(t *testing.T
m := newModel(context.Background(), Options{})
m.width = 44
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
next := updated.(model)
if got := next.composerValue(); got != paste {
@@ -198,10 +198,10 @@ func TestPastedLongSingleLineComposerContentRendersWrappedLineCount(t *testing.T
func TestBackspaceAfterPastedPreviewDeletesWholePaste(t *testing.T) {
paste := "first line\nsecond line\nthird line"
m := newModel(context.Background(), Options{})
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyBackspace))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace})
next = updated.(model)
if got := next.composerValue(); got != "" {
t.Fatalf("composer value after deleting paste preview = %q, want empty", got)
@@ -217,9 +217,9 @@ func TestAltBackspaceAfterPastedPreviewDoesNotLeakPaste(t *testing.T) {
m.input.SetValue("prefix ")
m.input.CursorEnd()
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
next := updated.(model)
updated, _ = next.Update(testKeyAlt(tea.KeyBackspace))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
next = updated.(model)
if got := next.composerValue(); got != "prefix " {
@@ -235,11 +235,11 @@ func TestBackspaceAfterTypedTextKeepsPastedPreviewCollapsed(t *testing.T) {
paste := "first line\nsecond line\nthird line"
m := newModel(context.Background(), Options{})
updated, _ := m.Update(testPaste(paste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
next := updated.(model)
updated, _ = next.Update(testKeyText("x"))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyBackspace))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace})
next = updated.(model)
if got := next.composerValue(); got != paste {
@@ -270,11 +270,11 @@ func TestPastingTwiceKeepsBothComposerPreviews(t *testing.T) {
}, "\n")
m := newModel(context.Background(), Options{})
updated, _ := m.Update(testPaste(firstPaste))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(firstPaste), Paste: true})
next := updated.(model)
updated, _ = next.Update(testKeyCtrl('j'))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlJ})
next = updated.(model)
updated, _ = next.Update(testPaste(secondPaste))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secondPaste), Paste: true})
next = updated.(model)
if got := next.composerValue(); got != firstPaste+"\n"+secondPaste {
@@ -296,10 +296,10 @@ func TestPastingTwiceKeepsBothComposerPreviews(t *testing.T) {
func TestModifiedEnterInsertsNewlineWithoutSubmitting(t *testing.T) {
tests := []struct {
name string
key tea.Msg
key tea.KeyMsg
}{
{name: "alt enter", key: testKeyAlt(tea.KeyEnter)},
{name: "shift enter", key: testKeyCtrl('j')},
{name: "alt enter", key: tea.KeyMsg{Type: tea.KeyEnter, Alt: true}},
{name: "shift enter", key: tea.KeyMsg{Type: tea.KeyCtrlJ}},
}
for _, tc := range tests {
@@ -328,7 +328,7 @@ func TestMultilineComposerEditingDoesNotFallBackToFlatInput(t *testing.T) {
m := newModel(context.Background(), Options{})
m.setComposerState(composerState{text: "alpha\nbeta gamma", cursor: len([]rune("alpha\nbeta"))})
updated, _ := m.Update(testKeyCtrl('u'))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
next := updated.(model)
if got := next.composerValue(); got != "alpha\n gamma" {
t.Fatalf("ctrl+u composer value = %q, want current line prefix removed", got)
@@ -337,7 +337,7 @@ func TestMultilineComposerEditingDoesNotFallBackToFlatInput(t *testing.T) {
t.Fatal("ctrl+u should keep multiline composer state active")
}
updated, _ = next.Update(testKeyCtrl('k'))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlK})
next = updated.(model)
if got := next.composerValue(); got != "alpha\n" {
t.Fatalf("ctrl+k composer value = %q, want current line suffix removed", got)
@@ -351,7 +351,7 @@ func TestMultilineComposerAcceptsSpaceKey(t *testing.T) {
m := newModel(context.Background(), Options{})
m.setComposerState(composerState{text: "alpha\nbetagamma", cursor: len([]rune("alpha\nbeta"))})
updated, _ := m.Update(testKey(tea.KeySpace))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
next := updated.(model)
if got := next.composerValue(); got != "alpha\nbeta gamma" {
@@ -370,7 +370,7 @@ func TestWrappedComposerArrowKeysMoveByVisualLine(t *testing.T) {
m.input.CursorEnd()
startCursor := len([]rune(text))
updated, _ := m.Update(testKey(tea.KeyUp))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
next := updated.(model)
if got := next.composerValue(); got != text {
t.Fatalf("composer value = %q, want unchanged text %q", got, text)
@@ -380,7 +380,7 @@ func TestWrappedComposerArrowKeysMoveByVisualLine(t *testing.T) {
t.Fatalf("up cursor = %d, want before end cursor %d", upCursor, startCursor)
}
updated, _ = next.Update(testKey(tea.KeyDown))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyDown})
next = updated.(model)
if got := next.currentComposerState().cursor; got != startCursor {
t.Fatalf("down cursor = %d, want restored end cursor %d", got, startCursor)
@@ -392,7 +392,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name string
start string
cursor int
key tea.Msg
key tea.KeyMsg
want string
wantCursor int
}{
@@ -400,7 +400,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "alt backspace skips trailing spaces",
start: "alpha beta ",
cursor: len([]rune("alpha beta ")),
key: testKeyAlt(tea.KeyBackspace),
key: tea.KeyMsg{Type: tea.KeyBackspace, Alt: true},
want: "alpha ",
wantCursor: len([]rune("alpha ")),
},
@@ -408,7 +408,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "ctrl w skips trailing spaces",
start: "alpha beta ",
cursor: len([]rune("alpha beta ")),
key: testKeyCtrl('w'),
key: tea.KeyMsg{Type: tea.KeyCtrlW},
want: "alpha ",
wantCursor: len([]rune("alpha ")),
},
@@ -416,7 +416,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "alt b moves back a word",
start: "alpha beta gamma",
cursor: len([]rune("alpha beta gamma")),
key: testKeyAltText("b"),
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true},
want: "alpha beta gamma",
wantCursor: len([]rune("alpha beta ")),
},
@@ -424,7 +424,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "ctrl left moves back a word",
start: "alpha beta gamma",
cursor: len([]rune("alpha beta gamma")),
key: testKeyPressMod(tea.KeyLeft, tea.ModCtrl),
key: tea.KeyMsg{Type: tea.KeyCtrlLeft},
want: "alpha beta gamma",
wantCursor: len([]rune("alpha beta ")),
},
@@ -432,7 +432,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "alt f moves forward a word",
start: "alpha beta gamma",
cursor: len([]rune("alpha ")),
key: testKeyAltText("f"),
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true},
want: "alpha beta gamma",
wantCursor: len([]rune("alpha beta")),
},
@@ -440,7 +440,7 @@ func TestComposerTerminalWordKeybindings(t *testing.T) {
name: "ctrl right moves forward a word",
start: "alpha beta gamma",
cursor: len([]rune("alpha ")),
key: testKeyPressMod(tea.KeyRight, tea.ModCtrl),
key: tea.KeyMsg{Type: tea.KeyCtrlRight},
want: "alpha beta gamma",
wantCursor: len([]rune("alpha beta")),
},
+6 -6
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
@@ -121,7 +121,7 @@ func TestDoctorConnectivityCommandRunsProbeAsynchronously(t *testing.T) {
})
m.input.SetValue("/doctor --connectivity")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor --connectivity to return an async command")
@@ -157,7 +157,7 @@ func TestDoctorCommandUsesDiagnosticCenterRow(t *testing.T) {
})
m.input.SetValue("/doctor")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected plain /doctor to render synchronously")
@@ -204,7 +204,7 @@ func TestDoctorConnectivityCommandAnimatesAndReplacesStatusRow(t *testing.T) {
})
m.input.SetValue("/doctor --connectivity")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor --connectivity to start an async command")
@@ -242,7 +242,7 @@ func TestDoctorFixOpensProviderWizardWhenProviderMissing(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/doctor fix")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /doctor fix provider setup path to be handled synchronously")
@@ -322,7 +322,7 @@ func TestDoctorFixRunsConnectivityWhenProviderConfigured(t *testing.T) {
})
m.input.SetValue("/doctor fix")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected /doctor fix to run provider connectivity asynchronously")
+1 -1
View File
@@ -3,7 +3,7 @@ package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
)
+9 -9
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -30,7 +30,7 @@ func TestSettledRowsAdvanceFrontierAndLeaveLiveView(t *testing.T) {
if cmd == nil {
t.Fatal("expected a scrollback print command for the settled rows")
}
view := viewString(next.View())
view := next.View()
if strings.Contains(view, "hello there") || strings.Contains(view, "noted") {
t.Fatalf("flushed rows must not re-render in the live view, got %q", view)
}
@@ -50,7 +50,7 @@ func TestAltScreenKeepsSettledRowsInManagedView(t *testing.T) {
if next.flushed != 0 {
t.Fatalf("alt-screen mode should keep the flush frontier unchanged, got %d", next.flushed)
}
view := viewString(next.View())
view := next.View()
if !strings.Contains(view, "hello there") || !strings.Contains(view, "noted") {
t.Fatalf("settled rows should remain in the managed alt-screen view, got %q", view)
}
@@ -103,7 +103,7 @@ func TestClearResetsFlushFrontier(t *testing.T) {
}
m.input.SetValue("/clear")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: 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)
@@ -120,7 +120,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
m.runCancel = func() {}
m.streamingText = "half an answer"
updated, _ := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
next := updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
@@ -154,21 +154,21 @@ func TestComposerHistoryRecall(t *testing.T) {
m := sizedTestModel(80)
for _, prompt := range []string{"first input", "second input"} {
m.input.SetValue(prompt)
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(model)
}
updated, _ := m.Update(testKey(tea.KeyUp))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(model)
if got := m.input.Value(); got != "second input" {
t.Fatalf("first ↑ should recall the newest input, got %q", got)
}
updated, _ = m.Update(testKey(tea.KeyUp))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(model)
if got := m.input.Value(); got != "first input" {
t.Fatalf("second ↑ should recall the older input, got %q", got)
}
updated, _ = m.Update(testKey(tea.KeyDown))
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(model)
if got := m.input.Value(); got != "second input" {
t.Fatalf("↓ should walk back toward the newest input, got %q", got)
-94
View File
@@ -1,94 +0,0 @@
package tui
import tea "charm.land/bubbletea/v2"
func keyCode(msg tea.KeyMsg) rune {
return msg.Key().Code
}
func keyText(msg tea.KeyMsg) string {
return msg.Key().Text
}
func keyRunes(msg tea.KeyMsg) []rune {
return []rune(keyText(msg))
}
func keyHasMod(msg tea.KeyMsg, mod tea.KeyMod) bool {
return msg.Key().Mod.Contains(mod)
}
func keyAlt(msg tea.KeyMsg) bool {
return keyHasMod(msg, tea.ModAlt)
}
func keyShift(msg tea.KeyMsg) bool {
return keyHasMod(msg, tea.ModShift)
}
func keyIs(msg tea.KeyMsg, code rune) bool {
return keyCode(msg) == code
}
func keyCtrl(msg tea.KeyMsg, code rune) bool {
return keyCode(msg) == code && keyHasMod(msg, tea.ModCtrl)
}
func keyCtrlArrow(msg tea.KeyMsg, code rune) bool {
return keyIs(msg, code) && keyHasMod(msg, tea.ModCtrl)
}
func keyPrintable(msg tea.KeyMsg) bool {
return keyText(msg) != "" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl)
}
func keyBackspace(msg tea.KeyMsg) bool {
return keyIs(msg, tea.KeyBackspace) || keyCtrl(msg, 'h')
}
func mouseEvent(msg tea.MouseMsg) tea.Mouse {
return msg.Mouse()
}
func mouseX(msg tea.MouseMsg) int {
return mouseEvent(msg).X
}
func mouseY(msg tea.MouseMsg) int {
return mouseEvent(msg).Y
}
func mouseLeftPress(msg tea.MouseMsg) bool {
event := mouseEvent(msg)
return event.Button == tea.MouseLeft && isMouseClick(msg)
}
func mouseRightPress(msg tea.MouseMsg) bool {
event := mouseEvent(msg)
return event.Button == tea.MouseRight && isMouseClick(msg)
}
func mouseMotion(msg tea.MouseMsg) bool {
_, ok := msg.(tea.MouseMotionMsg)
return ok
}
func mouseRelease(msg tea.MouseMsg) bool {
_, ok := msg.(tea.MouseReleaseMsg)
return ok
}
func mouseWheelUp(msg tea.MouseMsg) bool {
event := mouseEvent(msg)
return event.Button == tea.MouseWheelUp
}
func mouseWheelDown(msg tea.MouseMsg) bool {
event := mouseEvent(msg)
return event.Button == tea.MouseWheelDown
}
func isMouseClick(msg tea.MouseMsg) bool {
_, ok := msg.(tea.MouseClickMsg)
return ok
}
-85
View File
@@ -1,85 +0,0 @@
package tui
import (
"fmt"
tea "charm.land/bubbletea/v2"
)
func testKey(code rune) tea.KeyPressMsg {
text := ""
if code == tea.KeySpace {
text = " "
}
return tea.KeyPressMsg(tea.Key{Code: code, Text: text})
}
func testKeyText(text string) tea.KeyPressMsg {
runes := []rune(text)
code := tea.KeyExtended
if len(runes) == 1 {
code = runes[0]
}
return tea.KeyPressMsg(tea.Key{Code: code, Text: text})
}
func testKeyAlt(code rune) tea.KeyPressMsg {
return testKeyPressMod(code, tea.ModAlt)
}
func testKeyAltText(text string) tea.KeyPressMsg {
msg := testKeyText(text)
key := msg.Key()
key.Mod = tea.ModAlt
return tea.KeyPressMsg(key)
}
func testKeyCtrl(code rune) tea.KeyPressMsg {
return testKeyPressMod(code, tea.ModCtrl)
}
func testKeyShift(code rune) tea.KeyPressMsg {
return testKeyPressMod(code, tea.ModShift)
}
func testKeyPressMod(code rune, mod tea.KeyMod) tea.KeyPressMsg {
msg := testKey(code)
key := msg.Key()
key.Mod = mod
return tea.KeyPressMsg(key)
}
func testPaste(content string) tea.PasteMsg {
return tea.PasteMsg{Content: content}
}
func testMouseClick(button tea.MouseButton, x int, y int) tea.MouseClickMsg {
return tea.MouseClickMsg(tea.Mouse{Button: button, X: x, Y: y})
}
func testMouseWheel(button tea.MouseButton, x int, y int) tea.MouseWheelMsg {
return tea.MouseWheelMsg(tea.Mouse{Button: button, X: x, Y: y})
}
func testMouseMotion(button tea.MouseButton, x int, y int) tea.MouseMotionMsg {
return tea.MouseMotionMsg(tea.Mouse{Button: button, X: x, Y: y})
}
func testMouseRelease(button tea.MouseButton, x int, y int) tea.MouseReleaseMsg {
return tea.MouseReleaseMsg(tea.Mouse{Button: button, X: x, Y: y})
}
func viewString(view tea.View) string {
return view.Content
}
func renderContent(rendered any) string {
switch v := rendered.(type) {
case string:
return v
case tea.View:
return v.Content
default:
return fmt.Sprint(v)
}
}
-185
View File
@@ -1,185 +0,0 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
func TestTranscriptFrameLayoutPinsMainRegions(t *testing.T) {
m := mouseTestModel()
m.width = 100
m.height = 24
m.providerName = "openai"
m.modelName = "gpt-4.1"
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.headerRect != (tuiRect{width: width, height: len(frame.headerLines)}) {
t.Fatalf("header rect = %#v, want y=0 width=%d height=%d", frame.headerRect, width, len(frame.headerLines))
}
if frame.bodyRect.y != frame.headerRect.height {
t.Fatalf("body y = %d, want below header height %d", frame.bodyRect.y, frame.headerRect.height)
}
if frame.footerRect.y != frame.bodyRect.y+frame.bodyRect.height {
t.Fatalf("footer y = %d, want after body at %d", frame.footerRect.y, frame.bodyRect.y+frame.bodyRect.height)
}
if frame.footerRect.y+frame.footerRect.height != m.height {
t.Fatalf("footer bottom = %d, want terminal height %d", frame.footerRect.y+frame.footerRect.height, m.height)
}
if frame.composerRect.height <= 0 ||
!frame.footerRect.contains(frame.composerRect.x, frame.composerRect.y) ||
!frame.footerRect.contains(frame.composerRect.x+frame.composerRect.width-1, frame.composerRect.y+frame.composerRect.height-1) {
t.Fatalf("composer rect should be visible inside footer, frame=%#v", frame)
}
if frame.statusRect.height != 1 || frame.statusRect.y != frame.footerRect.y+frame.footerRect.height-1 {
t.Fatalf("status rect = %#v, want final visible footer line", frame.statusRect)
}
}
func TestTranscriptFrameLayoutClipsFooterInTinyTerminal(t *testing.T) {
m := mouseTestModel()
m.width = 44
m.height = 3
m.copyStatus = "Copied!"
m.input.SetValue("Create a book library dashboard page with cards, filters, charts, and responsive behavior.")
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.bodyRect.height != 1 {
t.Fatalf("body height = %d, want one protected viewport line", frame.bodyRect.height)
}
if frame.footerRect.height != m.height-1 {
t.Fatalf("footer height = %d, want clipped to %d", frame.footerRect.height, m.height-1)
}
if frame.headerRect.height != 0 {
t.Fatalf("header should clip out before body disappears, got %#v", frame.headerRect)
}
if frame.footerClip <= 0 {
t.Fatalf("footerClip = %d, want clipped full footer", frame.footerClip)
}
if len(frame.footerLines) >= len(frame.fullFooterLines) {
t.Fatalf("footer lines were not clipped: visible=%d full=%d", len(frame.footerLines), len(frame.fullFooterLines))
}
}
func TestTranscriptFrameLayoutHandlesDegenerateDimensions(t *testing.T) {
for _, size := range []struct {
name string
width int
height int
}{
{name: "zero", width: 0, height: 0},
{name: "negative", width: -10, height: -4},
} {
t.Run(size.name, func(t *testing.T) {
m := mouseTestModel()
m.width = size.width
m.height = size.height
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if width <= 0 {
t.Fatalf("chatWidth(%d) = %d, want positive fallback", size.width, width)
}
if frame.bodyRect.height < 1 {
t.Fatalf("body rect = %#v, want protected body height", frame.bodyRect)
}
if frame.width != width {
t.Fatalf("frame width = %d, want chat width %d", frame.width, width)
}
})
}
}
func TestFrameComposerRegionDrivesMouseHit(t *testing.T) {
m := mouseTestModel()
m.width = 44
m.height = 20
m.input.SetValue("Create a book library dashboard page with cards, filters, charts, and responsive behavior.")
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
if !m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
t.Fatalf("mouse over composer y=%d should hit composer rect %#v", frame.composerRect.y, frame.composerRect)
}
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.statusRect.y)) {
t.Fatalf("mouse over status y=%d should not hit composer rect %#v", frame.statusRect.y, frame.composerRect)
}
}
func TestOverlayMouseRectCentersInsideTranscriptBody(t *testing.T) {
m := mouseTestModel()
m.width = 100
m.height = 30
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
rect := m.overlayMouseRect(5, width)
wantY := frame.bodyRect.y + (frame.bodyRect.height-5)/2
if rect.y != wantY || rect.height != 5 || rect.width != width {
t.Fatalf("overlay rect = %#v, want y=%d height=5 width=%d", rect, wantY, width)
}
if !frame.bodyRect.contains(rect.x, rect.y) || !frame.bodyRect.contains(rect.x, rect.y+rect.height-1) {
t.Fatalf("overlay rect %#v should be inside body rect %#v", rect, frame.bodyRect)
}
}
func TestOverlayMouseHitClampsToVisibleBody(t *testing.T) {
m := mouseTestModel()
m.width = 80
m.height = 8
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
overlay := strings.Join([]string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"}, "\n")
rect := m.overlayMouseRect(len(viewLines(overlay)), width)
if rect.height != frame.bodyRect.height {
t.Fatalf("overlay rect = %#v, want height clamped to body rect %#v", rect, frame.bodyRect)
}
if _, ok := m.overlayMouseHit(testMouseClick(tea.MouseLeft, 1, rect.y+rect.height-1), overlay, width); !ok {
t.Fatalf("click inside visible overlay rect %#v should hit", rect)
}
if _, ok := m.overlayMouseHit(testMouseClick(tea.MouseLeft, 1, rect.y+rect.height), overlay, width); ok {
t.Fatalf("click below visible overlay rect %#v should not hit invisible overlay rows", rect)
}
}
func TestTranscriptLineAtMouseUsesFrameBodyRegion(t *testing.T) {
m := mouseTestModel()
m.width = 90
m.height = 12
m.transcript = appendRow(m.transcript, rowUser, "target text")
width := chatWidth(m.width)
body, selectable := m.transcriptBody(width, "")
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
start, _, _ := transcriptViewportStartForFrame(body, frame, m.chatScrollOffset)
var target transcriptSelectableLine
for _, line := range selectable {
if strings.Contains(line.text, "target text") {
target = line
break
}
}
if target.text == "" {
t.Fatalf("target line missing from selectable lines: %#v", selectable)
}
hit, ok := m.transcriptLineAtMouse(testMouseClick(tea.MouseLeft, target.textStart, frame.bodyRect.y+target.bodyY-start))
if !ok || hit.text != target.text {
t.Fatalf("transcript line hit = %#v ok=%v, want %#v", hit, ok, target)
}
if _, ok := m.transcriptLineAtMouse(testMouseClick(tea.MouseLeft, target.textStart, frame.headerRect.y)); ok {
t.Fatal("header click should not map to transcript body")
}
}
+12 -12
View File
@@ -6,7 +6,7 @@ import (
"strings"
"unicode"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
internalmcp "github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/redaction"
@@ -112,37 +112,37 @@ func (m model) handleMCPAddWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
return m, nil
}
wizard := m.mcpAddWizard
switch {
case keyIs(msg, tea.KeyEsc):
switch msg.Type {
case tea.KeyEsc:
m.cancelMCPCommand()
m.mcpAddWizard = nil
return m, nil
case keyBackspace(msg):
case tea.KeyBackspace, tea.KeyCtrlH:
wizard.deleteRune()
return m, nil
case keyCtrl(msg, 'u'):
case tea.KeyCtrlU:
wizard.clearCurrentInput()
return m, nil
case keyIs(msg, tea.KeyLeft):
case tea.KeyLeft:
wizard.back()
return m, nil
case keyIs(msg, tea.KeyUp):
case tea.KeyUp:
if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult {
wizard.move(-1)
}
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
case tea.KeyDown, tea.KeyTab:
if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult {
wizard.move(1)
}
return m, nil
case keyText(msg) != "":
case tea.KeyRunes:
if wizard.step == mcpAddWizardStepResult {
return m.handleMCPAddWizardResultShortcut(msg)
}
wizard.appendRunes(keyRunes(msg))
wizard.appendRunes(msg.Runes)
return m, nil
case keyIs(msg, tea.KeyEnter) || keyIs(msg, tea.KeyRight):
case tea.KeyEnter, tea.KeyRight:
if wizard.step == mcpAddWizardStepResult {
return m.handleMCPAddWizardResultEnter()
}
@@ -235,7 +235,7 @@ func (m model) handleMCPAddWizardResultShortcut(msg tea.KeyMsg) (model, tea.Cmd)
if m.mcpAddWizard == nil {
return m, nil
}
switch strings.ToLower(keyText(msg)) {
switch strings.ToLower(string(msg.Runes)) {
case "e", "r":
m.mcpAddWizard.step = mcpAddWizardStepEndpoint
m.mcpAddWizard.err = ""
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss"
"github.com/Gitlawb/zero/internal/redaction"
)
+13 -13
View File
@@ -4,8 +4,8 @@ import (
"strings"
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
@@ -109,26 +109,26 @@ func (m model) handleMCPManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
if m.mcpManager == nil {
return m, nil
}
switch {
case keyIs(msg, tea.KeyEsc):
switch msg.Type {
case tea.KeyEsc:
m.mcpManager = nil
case keyIs(msg, tea.KeyUp):
case tea.KeyUp:
m.moveMCPManager(-1)
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
case tea.KeyDown, tea.KeyTab:
m.moveMCPManager(1)
case keyIs(msg, tea.KeyEnter):
case tea.KeyEnter:
return m.chooseMCPManagerItem()
case keyBackspace(msg) || keyIs(msg, tea.KeyDelete):
case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlH:
m.deleteMCPManagerQueryRune()
case keyCtrl(msg, 'u'):
case tea.KeyCtrlU:
m.mcpManager.query = ""
m.mcpManager.selected = 0
case keyText(msg) != "":
if !keyAlt(msg) {
m.appendMCPManagerQuery(keyRunes(msg))
case tea.KeyRunes:
if !msg.Alt {
m.appendMCPManagerQuery(msg.Runes)
return m, nil
}
switch strings.ToLower(keyText(msg)) {
switch strings.ToLower(string(msg.Runes)) {
case "a":
return m.openMCPAddWizard("http"), nil
case "s":
+50
View File
@@ -113,6 +113,19 @@ func renderMCPView(state MCPViewState, width int) string {
return fitMCPManagerLines(lines, width)
}
func mcpViewStatus(state MCPViewState) commandStatus {
if !hasMCPOperationalContent(state) {
return commandStatusWarning
}
for _, server := range state.Servers {
status := strings.ToLower(strings.TrimSpace(server.State))
if strings.Contains(status, "error") || strings.Contains(status, "failed") {
return commandStatusBlocked
}
}
return commandStatusOK
}
func hasMCPViewContent(state MCPViewState) bool {
return hasMCPOperationalContent(state) ||
hasMCPPermissionActivity(state.Permissions)
@@ -133,6 +146,31 @@ func hasMCPPermissionActivity(summary MCPPermissionSummary) bool {
len(summary.Grants) > 0
}
func mcpServerLines(servers []MCPServerView) []string {
lines := make([]string, 0, len(servers))
for _, server := range servers {
name := displayValue(strings.TrimSpace(server.Name), "unnamed")
transport := displayValue(strings.TrimSpace(server.Transport), "unknown")
state := displayValue(strings.TrimSpace(server.State), "configured")
line := fmt.Sprintf("%s [%s] %s", name, transport, state)
if auth := strings.TrimSpace(server.Auth); auth != "" {
line += " " + auth
}
if server.ToolCount > 0 {
line += " " + pluralCount(server.ToolCount, "tool")
}
if target := strings.TrimSpace(server.Target); target != "" {
line += " - " + target
}
lines = append(lines, commandBullet(line))
if actions := mcpServerActionLine(server); actions != "" {
lines = append(lines, actions)
}
}
return lines
}
func mcpManagerServerLines(servers []MCPServerView) []string {
lines := make([]string, 0, len(servers)*3)
for index, server := range servers {
@@ -342,6 +380,18 @@ func dedupeStrings(values []string) []string {
return out
}
func fitCommandOutput(output commandOutput, width int) string {
rendered := renderCommandOutput(output)
if width <= 0 {
return rendered
}
lines := strings.Split(rendered, "\n")
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return strings.Join(lines, "\n")
}
func fitMCPManagerLines(lines []string, width int) string {
if width <= 0 {
return strings.Join(lines, "\n")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/lipgloss"
)
func TestMCPViewRendersEmptyState(t *testing.T) {
+117 -564
View File
File diff suppressed because it is too large. Load diff
+59 -320
View File
@@ -5,13 +5,12 @@ import (
"context"
"errors"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
@@ -77,7 +76,7 @@ func TestPromptSubmitInjectsLiveSessionModelContext(t *testing.T) {
})
m.input.SetValue("which model are you")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
@@ -137,7 +136,7 @@ func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) {
}
m.input.SetValue("hello")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
@@ -248,19 +247,19 @@ func TestTranscriptReducer(t *testing.T) {
func TestInitialRenderShowsLimeChatSurface(t *testing.T) {
model := newModel(context.Background(), Options{
Cwd: `/workspace/zero`,
Cwd: `D:\codings\Opensource\Zero`,
ProviderName: "openai",
ModelName: "gpt-4.1",
})
model.width = 120
model.height = 34
view := viewString(model.View())
assertContains(t, view, `/workspace/zero`)
view := model.View()
assertContains(t, view, " 0 ")
assertContains(t, view, "zero")
assertContains(t, view, "openai/gpt-4.1")
assertContains(t, view, emptyStateTagline)
assertNotContains(t, view, "running zero against ")
assertNotContains(t, view, " 0 ")
assertContains(t, view, composerPlaceholder)
assertNotContains(t, view, "interactive")
if strings.Contains(view, "Welcome to Zero") {
@@ -274,7 +273,7 @@ func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
m.height = 30
m.input.SetValue("inspect the repo")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
next.width = m.width
next.height = m.height
@@ -288,7 +287,7 @@ func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
if next.flushed != len(next.transcript) {
t.Fatalf("expected settled rows to flush to scrollback, flushed=%d rows=%d", next.flushed, len(next.transcript))
}
view := viewString(next.View())
view := next.View()
if strings.Contains(view, emptyStateTagline) {
t.Fatalf("empty state should collapse after first prompt, got %q", view)
}
@@ -303,12 +302,12 @@ func TestEmptyStateStaysVisibleOnEmptySubmit(t *testing.T) {
m.height = 30
m.input.SetValue(" ")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
next.width = m.width
next.height = m.height
view := viewString(next.View())
view := next.View()
assertContains(t, view, emptyStateTagline)
assertNotContains(t, view, "❯ inspect")
}
@@ -317,7 +316,7 @@ func TestHelpCommandAppendsHelpRow(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/help")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "/tools") {
@@ -333,7 +332,7 @@ func TestClearCommandResetsTranscript(t *testing.T) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
m.input.SetValue("/clear")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
@@ -347,7 +346,7 @@ func TestToolsCommandListsRegisteredTools(t *testing.T) {
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/tools")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "read_file") {
@@ -381,7 +380,7 @@ func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) {
})
m.input.SetValue("/permissions")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -429,7 +428,7 @@ func TestPlanCommandShowsCurrentPlan(t *testing.T) {
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/plan")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -446,7 +445,7 @@ func TestPlanCommandHandlesMissingPlanTool(t *testing.T) {
m := newModel(context.Background(), Options{Registry: tools.NewRegistry()})
m.input.SetValue("/plan")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "No plan is active") {
@@ -466,7 +465,7 @@ func TestContextCommandShowsSessionState(t *testing.T) {
})
m.input.SetValue("/context")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -495,7 +494,7 @@ func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) {
})
m.input.SetValue("/model list")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -535,7 +534,7 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -554,57 +553,6 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
}, true); err != nil {
t.Fatalf("write user config: %v", err)
}
m := newModel(context.Background(), Options{
UserConfigPath: configPath,
ProviderName: "openai",
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
},
Provider: &fakeProvider{},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1-mini" {
t.Fatalf("modelName = %q, want gpt-4.1-mini", next.modelName)
}
persisted, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatalf("resolve persisted config: %v", err)
}
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !transcriptContains(next.transcript, "saved: user config") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
}
}
type stubModelSwitchCompactionGuard struct {
decision modelSwitchCompactionDecision
requests []modelSwitchCompactionRequest
@@ -665,7 +613,7 @@ func TestModelCommandRequestsCompactionBeforeDirtyContextSwitch(t *testing.T) {
m.sessionEvents = []sessions.Event{{Sequence: 1, Type: sessions.EventMessage}}
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -712,7 +660,7 @@ func TestModelCommandRequiresProviderRebuildForSwitch(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -739,7 +687,7 @@ func TestModelCommandRejectsSwitchWhilePending(t *testing.T) {
m.pending = true
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -768,7 +716,7 @@ func TestModelCommandReportsProviderRebuildErrors(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1-mini")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if next.modelName != "gpt-4.1" {
@@ -790,7 +738,7 @@ func TestDoctorCommandUsesCurrentProviderProfile(t *testing.T) {
})
m.input.SetValue("/doctor")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -826,7 +774,7 @@ func TestSearchCommandUsesSessionStore(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/search needle")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -841,7 +789,7 @@ func TestSearchCommandRequiresQuery(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/search")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "usage: /search <query>") {
@@ -868,184 +816,29 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
t.Fatal("expected /resume to be handled without starting an agent run")
}
// Bare /resume now opens the interactive session picker (like /model & /provider).
if next.picker == nil || next.picker.kind != pickerSession {
t.Fatalf("expected /resume to open the session picker, got picker=%#v", next.picker)
if !transcriptContains(next.transcript, "Newer") || !transcriptContains(next.transcript, "Older") {
t.Fatalf("expected session list in transcript, got %#v", next.transcript)
}
// Every row carries the session title (in the Label, after the timestamp) and
// resolves to / shows the session id (Value + Meta), for both sessions.
findByID := func(id string) (pickerItem, bool) {
for _, item := range next.picker.items {
if item.Value == id {
return item, true
}
}
return pickerItem{}, false
}
for _, want := range []struct{ title, id string }{{"Newer", second.SessionID}, {"Older", first.SessionID}} {
item, ok := findByID(want.id)
if !ok {
t.Fatalf("picker missing session id %q; items=%#v", want.id, next.picker.items)
}
if !strings.Contains(item.Label, want.title) {
t.Fatalf("picker Label %q should contain the title %q", item.Label, want.title)
}
if !strings.Contains(item.Meta, want.id) {
t.Fatalf("picker %q Meta should show the id %q, got %q", want.title, want.id, item.Meta)
}
}
// The picker overlay renders the titles and ids.
view := viewString(next.View())
for _, want := range []string{"Resume a session", "Newer", "Older", first.SessionID, second.SessionID} {
// The list renders as stacked cards: id + age + title + meta per session.
view := next.View()
for _, want := range []string{first.SessionID, second.SessionID, "1 events", "anthropic"} {
if !strings.Contains(view, want) {
t.Fatalf("session picker view missing %q:\n%s", want, view)
t.Fatalf("sessions card view missing %q:\n%s", want, view)
}
}
}
func TestResumePickerSelectionHydratesSession(t *testing.T) {
store := testSessionStore(t)
target, err := store.Create(sessions.CreateInput{Title: "Pick me", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create target: %v", err)
}
if _, err := store.AppendEvent(target.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "hello"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.Create(sessions.CreateInput{Title: "Other", ModelID: "x", Provider: "y"}); err != nil {
t.Fatalf("Create other: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerSession {
t.Fatalf("expected the session picker to open, got %#v", m.picker)
}
for i, item := range m.picker.items {
if item.Value == target.SessionID {
m.picker.selected = i
}
}
updated, cmd := m.Update(testKey(tea.KeyEnter)) // choosePicker
next := updated.(model)
if cmd != nil {
t.Fatal("selecting a session to resume should not start an agent run")
}
if next.picker != nil {
t.Fatal("picker should close after a selection")
}
if next.activeSession.SessionID != target.SessionID {
t.Fatalf("active session = %q, want %q", next.activeSession.SessionID, target.SessionID)
}
if !transcriptContains(next.transcript, "Resumed Zero session") || !transcriptContains(next.transcript, target.SessionID) {
t.Fatalf("expected the resume summary in the transcript, got %#v", next.transcript)
}
}
func TestResumePickerHidesEmptyFailedSessions(t *testing.T) {
store := testSessionStore(t)
real, err := store.Create(sessions.CreateInput{Title: "Real one", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create real: %v", err)
}
if _, err := store.AppendEvent(real.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "do a thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.AppendEvent(real.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "here is the thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
// An empty/failed run: prompt + the no-output guardrail stop, nothing else.
empty, err := store.Create(sessions.CreateInput{Title: "Empty one", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create empty: %v", err)
}
if _, err := store.AppendEvent(empty.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "do a thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.AppendEvent(empty.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "Agent stopped after 3 turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."}}); err != nil {
t.Fatalf("Append: %v", err)
}
picker := newModel(context.Background(), Options{SessionStore: store}).newSessionPicker()
if picker == nil {
t.Fatal("expected a picker containing the real session")
}
for _, item := range picker.items {
if item.Value == empty.SessionID {
t.Fatalf("empty/no-output session must be hidden from the picker: %#v", picker.items)
}
}
shown := false
for _, item := range picker.items {
if item.Value == real.SessionID {
shown = true
}
}
if !shown {
t.Fatalf("the real session must be shown: %#v", picker.items)
}
}
func TestResumeHonorsPriorCompaction(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{Title: "Compacted", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create: %v", err)
}
for _, content := range []string{"alpha", "beta", "gamma", "delta"} {
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": content}}); err != nil {
t.Fatalf("Append: %v", err)
}
}
plan, err := store.PlanCompaction(session.SessionID, sessions.CompactionOptions{PreserveLast: 2, MaxPromptChars: 500})
if err != nil {
t.Fatalf("PlanCompaction: %v", err)
}
if _, err := store.RecordCompaction(session.SessionID, sessions.RecordCompactionInput{Plan: plan, Summary: "early summary"}); err != nil {
t.Fatalf("RecordCompaction: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "epsilon"}}); err != nil {
t.Fatalf("Append epsilon: %v", err)
}
raw, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
rehydrated, err := store.ReadRehydratedEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadRehydratedEvents: %v", err)
}
if len(rehydrated) >= len(raw) {
t.Fatalf("test setup invalid: rehydrated (%d) should be < raw (%d)", len(rehydrated), len(raw))
}
m := newModel(context.Background(), Options{SessionStore: store})
next, _ := m.handleResumeCommand(session.SessionID)
// Resume must load the rehydrated (compaction-aware) context, not the raw log —
// matching the CLI's --resume and the in-TUI /compact reload. Compare contents,
// not just length: a regression that returned a same-length but reordered or
// substituted slice (e.g. a dropped original in place of the summary) would
// slip past a length check.
if !reflect.DeepEqual(next.sessionEvents, rehydrated) {
t.Fatalf("resumed sessionEvents do not match the rehydrated context (resume must honor prior compaction)\nresumed: %+v\nrehydrated: %+v\nraw: %+v", next.sessionEvents, rehydrated, raw)
}
}
func TestResumeCommandWithUnknownIDReportsMissingSession(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
m.input.SetValue("/resume zero_123")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "zero session not found: zero_123") {
@@ -1066,7 +859,7 @@ func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) {
})
m.input.SetValue("say hi")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if !transcriptContains(next.transcript, "say hi") {
t.Fatalf("expected user row after submit, got %#v", next.transcript)
@@ -1091,7 +884,7 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
m.pending = true
m.input.SetValue("second prompt")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if cmd != nil {
@@ -1112,7 +905,7 @@ func TestEscCancelsPendingRun(t *testing.T) {
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
next := updated.(model)
if !cancelled {
@@ -1235,7 +1028,7 @@ func TestPermissionRequestShowsFocusedPrompt(t *testing.T) {
t.Fatalf("expected permission request to append one permission row, got %#v", next.transcript)
}
view := next.View()
for _, want := range []string{"write_file", "allow once", "[a]", "deny", "[d]", "always", "[y]", "risk:high", "Creates or overwrites files."} {
for _, want := range []string{"write_file", "[a] allow", "[d] deny", "[y] always", "risk:high", "Creates or overwrites files."} {
assertContains(t, view, want)
}
}
@@ -1266,7 +1059,7 @@ func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
})
next := updated.(model)
updated, cmd := next.Update(testKeyText(tc.key))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)})
next = updated.(model)
if cmd != nil {
@@ -1297,22 +1090,20 @@ func TestPermissionPromptBlocksNormalSubmit(t *testing.T) {
next := updated.(model)
next.input.SetValue("second prompt")
updated, cmd := next.Update(testKey(tea.KeyEnter))
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter})
next = updated.(model)
if cmd != nil {
t.Fatal("expected permission confirm to resolve synchronously (no cmd)")
t.Fatal("expected Enter to be ignored while permission prompt is active")
}
// Enter confirms the highlighted option (default: allow once) — it must NOT
// submit the composer's pending text as a new prompt.
if len(decisions) != 1 || decisions[0] != permissionDecisionAllow {
t.Fatalf("expected Enter to confirm the default option (allow once), got %#v", decisions)
if len(decisions) != 0 {
t.Fatalf("expected Enter not to choose a permission decision, got %#v", decisions)
}
if transcriptContains(next.transcript, "second prompt") {
t.Fatalf("permission prompt should block normal prompt submit, got %#v", next.transcript)
}
if next.pendingPermission != nil {
t.Fatalf("expected permission prompt to clear after confirm, got %#v", next.pendingPermission)
if next.pendingPermission == nil {
t.Fatal("expected permission prompt to remain pending after Enter")
}
}
@@ -1483,7 +1274,7 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) {
agent.PermissionModeAsk,
agent.PermissionModeAuto,
} {
updated, cmd := m.Update(testKeyShift(tea.KeyTab))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
m = updated.(model)
if cmd != nil {
t.Fatalf("expected shift+tab to cycle mode synchronously, got command")
@@ -1512,7 +1303,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
m.activeRunID = 7
updated, _ := m.Update(permissionRequestMsg{runID: 7, request: testPromptPermissionRequest()})
next := updated.(model)
updated, _ = next.Update(testKeyShift(tea.KeyTab))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while permission modal is up, got %q", next.permissionMode)
@@ -1527,7 +1318,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
m.activeRunID = 7
updated, _ := m.Update(askUserRequestMsg{runID: 7, request: testAskUserRequest(), answer: func([]string) {}})
next := updated.(model)
updated, _ = next.Update(testKeyShift(tea.KeyTab))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while ask_user prompt is up, got %q", next.permissionMode)
@@ -1544,12 +1335,12 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
PermissionMode: agent.PermissionModeAuto,
})
m.input.SetValue("/model")
updated, _ := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
next := updated.(model)
if next.picker == nil {
t.Skip("model picker unavailable in test environment")
}
updated, _ = next.Update(testKeyShift(tea.KeyTab))
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while picker is open, got %q", next.permissionMode)
@@ -1560,7 +1351,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
func TestCtrlCExits(t *testing.T) {
m := newModel(context.Background(), Options{})
updated, cmd := m.Update(testKeyCtrl('c'))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
next := updated.(model)
if !next.exiting {
@@ -1571,21 +1362,19 @@ func TestCtrlCExits(t *testing.T) {
}
}
func assertContains(t *testing.T, text any, want string) {
func assertContains(t *testing.T, text string, want string) {
t.Helper()
content := plainRender(t, text)
if !strings.Contains(content, want) {
t.Fatalf("expected %q to contain %q", content, want)
if !strings.Contains(text, want) {
t.Fatalf("expected %q to contain %q", text, want)
}
}
func assertNotContains(t *testing.T, text any, unwanted string) {
func assertNotContains(t *testing.T, text string, unwanted string) {
t.Helper()
content := plainRender(t, text)
if strings.Contains(content, unwanted) {
t.Fatalf("expected %q not to contain %q", content, unwanted)
if strings.Contains(text, unwanted) {
t.Fatalf("expected %q not to contain %q", text, unwanted)
}
}
@@ -1733,53 +1522,3 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) {
t.Fatalf("refocused should be silent, got %q", buf.String())
}
}
// TestWorkingVerbAdvancesOnStepEverySpinnerTicks locks in the cadence
// decision: the verb advances every WorkingWordsStepEvery spinner ticks
// (~1Hz at the 80ms spinner cadence), not every frame. The model owns the
// counter so the dumb Tick() ring stays unit-testable without a real
// spinner.
//
// We drive WorkingWordsStepEvery-1 ticks and assert the verb didn't
// change, then drive one more tick and assert it did. Then we drive
// another WorkingWordsStepEvery-1 ticks and assert the verb stayed put
// (i.e. the counter reset after the advance, not carried over). This
// catches a regression where someone removes the gate (revert to "every
// tick"), sets the gate to 1 (same effect), or forgets to reset the
// counter after the advance (so a follow-up advance lands one window
// early).
func TestWorkingVerbAdvancesOnStepEverySpinnerTicks(t *testing.T) {
m := limeTestModel()
m.pending = true
before := m.workingVerb.Current()
// Drive (WorkingWordsStepEvery - 1) ticks. The verb must NOT advance.
for i := 0; i < WorkingWordsStepEvery-1; i++ {
updated, _ := m.Update(spinner.TickMsg{})
m = updated.(model)
}
if got := m.workingVerb.Current(); got != before {
t.Fatalf("after %d ticks verb = %q, want unchanged (%q)", WorkingWordsStepEvery-1, got, before)
}
// The (WorkingWordsStepEvery)th tick IS the advance.
updated, _ := m.Update(spinner.TickMsg{})
m = updated.(model)
afterAdvance := m.workingVerb.Current()
if afterAdvance == before {
t.Fatalf("after %d ticks verb still = %q, want advance", WorkingWordsStepEvery, before)
}
// Drive another (WorkingWordsStepEvery - 1) ticks. The verb must NOT
// advance again — the counter resets after each advance, not carries
// over. A regression that omits the reset (so the next advance lands
// one window early) would fail this assertion.
for i := 0; i < WorkingWordsStepEvery-1; i++ {
updated, _ := m.Update(spinner.TickMsg{})
m = updated.(model)
}
if got := m.workingVerb.Current(); got != afterAdvance {
t.Fatalf("after advance + %d ticks verb = %q, want unchanged from post-advance (%q) — counter didn't reset",
WorkingWordsStepEvery-1, got, afterAdvance)
}
}
+75 -42
View File
@@ -1,9 +1,8 @@
package tui
import tea "charm.land/bubbletea/v2"
import tea "github.com/charmbracelet/bubbletea"
type mouseOverlayHit struct {
x int
y int
}
@@ -15,20 +14,6 @@ type mouseSelectionTarget struct {
}
func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// While a provider-wizard OAuth login is in flight, ignore all mouse input
// (clicks and wheel) so a stray scroll can't change the selected provider
// mid-login — mirroring the keyboard handler's pending guard.
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
// (composer, wizard, setup) or swallowed on a surface with no text field.
// Keyboard/selection copy-paste keep working unchanged.
if mouseRightPress(msg) {
return m, pasteFromClipboardCmd()
}
if mouseLeftPress(msg) {
switch {
case m.providerWizard != nil:
@@ -179,7 +164,34 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
return m, nil
}
m.mouseCapture = want
return m, nil
if want {
return m, tea.EnableMouseCellMotion
}
return m, tea.DisableMouse
}
// Bubble Tea's Type field is deprecated, but its parser still populates it for
// compatibility cases such as left-button drag events. Keep these helpers
// tolerant of both the current Button/Action pair and legacy Type values.
func mouseLeftPress(msg tea.MouseMsg) bool {
return msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress ||
msg.Type == tea.MouseLeft && msg.Action == tea.MouseActionPress
}
func mouseMotion(msg tea.MouseMsg) bool {
return msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion
}
func mouseRelease(msg tea.MouseMsg) bool {
return msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease
}
func mouseWheelUp(msg tea.MouseMsg) bool {
return msg.Button == tea.MouseButtonWheelUp || msg.Type == tea.MouseWheelUp
}
func mouseWheelDown(msg tea.MouseMsg) bool {
return msg.Button == tea.MouseButtonWheelDown || msg.Type == tea.MouseWheelDown
}
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
@@ -187,8 +199,33 @@ func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
return false
}
width := chatWidth(m.width)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return frame.composerRect.contains(mouseX(msg), mouseY(msg))
composerLines := viewLines(m.composerBox(width))
fullFooterLines := viewLines(m.footerView(width))
if len(composerLines) == 0 || len(fullFooterLines) == 0 {
return false
}
composerTop := lineSequenceIndex(fullFooterLines, composerLines)
if composerTop < 0 {
return false
}
footerLines := fullFooterLines
clippedPrefix := 0
maxFooterLines := maxInt(0, m.height-1)
if len(footerLines) > maxFooterLines {
clippedPrefix = len(footerLines) - maxFooterLines
footerLines = footerLines[clippedPrefix:]
}
if len(footerLines) == 0 {
return false
}
visibleTop := maxInt(composerTop, clippedPrefix)
visibleBottom := minInt(composerTop+len(composerLines), clippedPrefix+len(footerLines))
if visibleTop >= visibleBottom {
return false
}
footerTop := maxInt(0, m.height-len(footerLines))
top := footerTop + visibleTop - clippedPrefix
return msg.Y >= top && msg.Y < top+visibleBottom-visibleTop
}
func lineSequenceIndex(lines []string, sequence []string) int {
@@ -384,7 +421,7 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
}
func (m *model) selectProviderWizardAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
if m.providerWizard == nil || m.providerWizard.oauthPending {
if m.providerWizard == nil {
return mouseSelectionTarget{}, false
}
width := chatWidth(m.width)
@@ -450,39 +487,35 @@ func (m model) overlayMouseHit(msg tea.MouseMsg, overlay string, width int) (mou
if overlayWidth <= 0 || len(lines) == 0 {
return mouseOverlayHit{}, false
}
rect := m.overlayMouseRect(len(lines), width)
if rect.height <= 0 || mouseY(msg) < rect.y || mouseY(msg) >= rect.y+rect.height {
top := m.overlayMouseTop(len(lines), width)
if msg.Y < top || msg.Y >= top+len(lines) {
return mouseOverlayHit{}, false
}
if mouseX(msg) < left || mouseX(msg) >= left+overlayWidth {
if msg.X < left || msg.X >= left+overlayWidth {
return mouseOverlayHit{}, false
}
return mouseOverlayHit{x: mouseX(msg) - left, y: mouseY(msg) - rect.y}, true
return mouseOverlayHit{y: msg.Y - top}, true
}
func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
func (m model) overlayMouseRect(overlayHeight int, width int) tuiRect {
if overlayHeight <= 0 {
return tuiRect{}
return 0
}
if m.altScreen && m.height > 0 {
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
visibleHeight := minInt(overlayHeight, frame.bodyRect.height)
if visibleHeight <= 0 {
return tuiRect{}
if m.transcriptEmpty() && !m.pending {
top := 0
available := normalizedStartupHeight(m.height) - 5
if !m.headerPrinted {
top += len(viewLines(m.titleBar(width)))
available -= 2
}
return top + maxInt(0, (available-overlayHeight)/2)
}
return tuiRect{
y: frame.bodyRect.y + maxInt(0, (frame.bodyRect.height-visibleHeight)/2),
width: width,
height: visibleHeight,
available := m.height - len(viewLines(m.footerView(width)))
if available < 1 {
available = 1
}
return maxInt(0, (available-overlayHeight)/2)
}
return tuiRect{
y: maxInt(0, (normalizedStartupHeight(m.height)-overlayHeight)/2),
width: width,
height: overlayHeight,
}
return maxInt(0, (normalizedStartupHeight(m.height)-overlayHeight)/2)
}
-28
View File
@@ -1,28 +0,0 @@
package tui
import (
"time"
tea "charm.land/bubbletea/v2"
)
const mouseEventThrottleInterval = 15 * time.Millisecond
func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg {
return newMouseEventFilter(time.Now, mouseEventThrottleInterval)
}
func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg {
var last time.Time
return func(_ tea.Model, msg tea.Msg) tea.Msg {
switch msg.(type) {
case tea.MouseWheelMsg, tea.MouseMotionMsg:
current := now()
if !last.IsZero() && current.Sub(last) < minInterval {
return nil
}
last = current
}
return msg
}
}
-53
View File
@@ -1,53 +0,0 @@
package tui
import (
"testing"
"time"
tea "charm.land/bubbletea/v2"
)
func TestMouseEventFilterThrottlesWheelAndMotion(t *testing.T) {
base := time.Unix(0, 0)
times := []time.Time{
base,
base.Add(10 * time.Millisecond),
base.Add(14 * time.Millisecond),
base.Add(15 * time.Millisecond),
}
index := 0
filter := newMouseEventFilter(func() time.Time {
current := times[index]
index++
return current
}, 15*time.Millisecond)
if got := filter(nil, testMouseWheel(tea.MouseWheelDown, 0, 0)); got == nil {
t.Fatal("first wheel event should pass through")
}
if got := filter(nil, tea.MouseMotionMsg(tea.Mouse{X: 1, Y: 1})); got != nil {
t.Fatal("motion event inside throttle window should be dropped")
}
if got := filter(nil, testMouseWheel(tea.MouseWheelUp, 0, 0)); got != nil {
t.Fatal("wheel event inside throttle window should be dropped")
}
if got := filter(nil, tea.MouseMotionMsg(tea.Mouse{X: 2, Y: 2})); got == nil {
t.Fatal("mouse event at throttle boundary should pass through")
}
}
func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) {
called := false
filter := newMouseEventFilter(func() time.Time {
called = true
return time.Unix(0, 0)
}, 15*time.Millisecond)
msg := testKey('x')
if got := filter(nil, msg); got != msg {
t.Fatalf("keyboard event = %#v, want original message", got)
}
if called {
t.Fatal("keyboard events should not touch the mouse throttle clock")
}
}
+116 -88
View File
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
tea "github.com/charmbracelet/bubbletea"
"github.com/Gitlawb/zero/internal/config"
)
@@ -19,7 +19,12 @@ func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
width := chatWidth(m.width)
top := m.overlayMouseTop(len(viewLines(m.suggestionOverlay(width))), width)
click := testMouseClick(tea.MouseLeft, width/2, top+3)
click := tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: width / 2,
Y: top + 3,
}
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
@@ -60,7 +65,12 @@ func TestMouseClickSelectsThenAppliesPickerRow(t *testing.T) {
width := chatWidth(m.width)
top := m.overlayMouseTop(len(viewLines(m.pickerOverlay(width))), width)
click := testMouseClick(tea.MouseLeft, width/2, top+3)
click := tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: width / 2,
Y: top + 3,
}
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
@@ -87,7 +97,6 @@ func TestMouseClickSelectsThenAppliesPickerRow(t *testing.T) {
func TestMouseClickSelectsProviderWizardRow(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
m.providerWizard.step = providerWizardStepProvider // skip the new method chooser
if m.providerWizard == nil || len(m.providerWizard.providers) < 2 {
t.Fatalf("expected multiple providers, got %#v", m.providerWizard)
}
@@ -95,7 +104,12 @@ func TestMouseClickSelectsProviderWizardRow(t *testing.T) {
width := chatWidth(m.width)
top := m.overlayMouseTop(len(viewLines(m.providerWizardOverlay(width))), width)
click := testMouseClick(tea.MouseLeft, width/2, top+5)
click := tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: width / 2,
Y: top + 5,
}
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
@@ -119,10 +133,9 @@ func TestMouseClickSelectsProviderWizardRow(t *testing.T) {
func TestMouseWheelMovesProviderWizardRows(t *testing.T) {
m := mouseTestModel()
m.providerWizard = m.newProviderWizard()
m.providerWizard.step = providerWizardStepProvider // skip the new method chooser
m.mouseCapture = true
updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0))
updated, cmd := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown})
next := updated.(model)
if cmd != nil {
t.Fatal("mouse wheel should not return a command")
@@ -141,7 +154,12 @@ func TestMouseClickSelectsThenContinuesSetupProviderRow(t *testing.T) {
height := normalizedStartupHeight(m.height)
rowWidth := setupProviderBlockWidth(width, m.setup.providers)
top := setupContentTop(height, len(m.setupProviderLines(width, height)), m.setup.err != "")
click := testMouseClick(tea.MouseLeft, maxInt(0, (width-rowWidth)/2)+2, top+3)
click := tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: maxInt(0, (width-rowWidth)/2) + 2,
Y: top + 3,
}
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
@@ -176,7 +194,12 @@ func TestMouseClickSelectsThenContinuesSetupModelRow(t *testing.T) {
height := normalizedStartupHeight(m.height)
rowWidth := setupModelBlockWidth(width, m.setup.models)
top := setupContentTop(height, len(m.setupModelLines(width, height)), m.setup.err != "")
click := testMouseClick(tea.MouseLeft, maxInt(0, (width-rowWidth)/2)+2, top+5)
click := tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: maxInt(0, (width-rowWidth)/2) + 2,
Y: top + 5,
}
updated, cmd := m.Update(click)
next := updated.(model)
if cmd != nil {
@@ -209,7 +232,7 @@ func TestMouseCaptureOnlyWhileInteractiveSurfaceOpen(t *testing.T) {
t.Fatalf("open command palette should capture mouse, wants=%v active=%v", m.wantsMouseCapture(), m.mouseCapture)
}
updated, cmd := m.Update(testKey(tea.KeyEsc))
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(model)
_ = cmd
if !m.wantsMouseCapture() || !m.mouseCapture {
@@ -233,9 +256,13 @@ func TestTranscriptSelectionOnlyStartsOnTranscriptText(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, 40, 20))
updated, cmd := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 40,
Y: 20,
})
next := updated.(model)
if cmd != nil {
t.Fatal("empty-area click should not return a command")
@@ -244,7 +271,12 @@ func TestTranscriptSelectionOnlyStartsOnTranscriptText(t *testing.T) {
t.Fatal("empty-area click should not start transcript selection")
}
updated, cmd = next.Update(testMouseClick(tea.MouseLeft, 3, textY))
updated, cmd = next.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 3,
Y: 1,
})
next = updated.(model)
if cmd != nil {
t.Fatal("transcript press should not copy yet")
@@ -258,11 +290,20 @@ func TestTranscriptSelectionExtractsVisibleTextRange(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
updated, _ := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 3,
Y: 1,
})
m = updated.(model)
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, textY))
updated, _ = m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionMotion,
X: 8,
Y: 1,
})
m = updated.(model)
if got := m.selectedTranscriptText(); got != "hello" {
@@ -274,11 +315,20 @@ func TestTranscriptSelectionUpdatesOnGenericMotion(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
updated, _ := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 3,
Y: 1,
})
m = updated.(model)
updated, _ = m.Update(testMouseMotion(tea.MouseNone, 8, textY))
updated, _ = m.Update(tea.MouseMsg{
Button: tea.MouseButtonNone,
Action: tea.MouseActionMotion,
X: 8,
Y: 1,
})
m = updated.(model)
if got := m.selectedTranscriptText(); got != "hello" {
@@ -290,13 +340,23 @@ func TestTranscriptSelectionLeftDragDoesNotResetAnchor(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
updated, _ := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 3,
Y: 1,
})
m = updated.(model)
// A left-button drag is Action==Motion with Button==Left; this must update the
// cursor without resetting the selection anchor.
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, textY))
// Bubble Tea marks left-button drag motion as Type MouseLeft for backward
// compatibility; this must update the cursor without resetting the anchor.
updated, _ = m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionMotion,
Type: tea.MouseLeft,
X: 8,
Y: 1,
})
m = updated.(model)
if got := m.selectedTranscriptText(); got != "hello" {
@@ -308,11 +368,20 @@ func TestTranscriptSelectionReleaseExtendsRangeWithoutMotion(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hello world")
textY := firstTranscriptTextMouseY(t, m)
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
updated, _ := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: 3,
Y: 1,
})
m = updated.(model)
updated, cmd := m.Update(testMouseRelease(tea.MouseNone, 8, textY))
updated, cmd := m.Update(tea.MouseMsg{
Button: tea.MouseButtonNone,
Action: tea.MouseActionRelease,
X: 8,
Y: 1,
})
m = updated.(model)
if cmd == nil {
t.Fatal("release after range selection should return copy command")
@@ -346,7 +415,7 @@ func TestMouseClickTogglesReasoningRow(t *testing.T) {
width := chatWidth(m.width)
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
start, _ := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.toggle {
@@ -358,7 +427,12 @@ func TestMouseClickTogglesReasoningRow(t *testing.T) {
t.Fatalf("expected reasoning header to be clickable, selectable=%#v", selectable)
}
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
updated, cmd := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: target.textStart,
Y: target.bodyY - start,
})
next := updated.(model)
if cmd != nil {
t.Fatal("reasoning toggle click should not return a command")
@@ -380,7 +454,7 @@ func TestMouseClickTogglesStreamingReasoning(t *testing.T) {
width := chatWidth(m.width)
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
start, _ := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.toggle && line.live {
@@ -392,7 +466,12 @@ func TestMouseClickTogglesStreamingReasoning(t *testing.T) {
t.Fatalf("expected live reasoning header to be clickable, selectable=%#v", selectable)
}
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
updated, cmd := m.Update(tea.MouseMsg{
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
X: target.textStart,
Y: target.bodyY - start,
})
next := updated.(model)
if cmd != nil {
t.Fatal("streaming reasoning toggle click should not return a command")
@@ -445,7 +524,7 @@ func TestMCPManagerMouseSelectsFirstItemRow(t *testing.T) {
left, _, _ := normalizeOverlayBlock(lines, width)
y := m.overlayMouseTop(len(lines), width) + mcpManagerFirstItemRow(m.mcpViewState())
target, ok := m.selectMCPManagerAtMouse(testMouseClick(tea.MouseLeft, left+2, y))
target, ok := m.selectMCPManagerAtMouse(tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: left + 2, Y: y})
if !ok {
t.Fatal("expected click on first manager item row to select")
}
@@ -465,7 +544,7 @@ func TestMCPAddWizardMouseSelectsAndActivatesType(t *testing.T) {
lines := viewLines(overlay)
left, _, _ := normalizeOverlayBlock(lines, width)
y := m.overlayMouseTop(len(lines), width) + 5 // second type row: top border + step + rule + title + first row
msg := testMouseClick(tea.MouseLeft, left+2, y)
msg := tea.MouseMsg{Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: left + 2, Y: y}
updated, cmd := m.Update(msg)
next := updated.(model)
@@ -486,47 +565,16 @@ func TestMCPAddWizardMouseSelectsAndActivatesType(t *testing.T) {
}
}
func TestMCPAddWizardBlocksTranscriptSelectionBehindOverlay(t *testing.T) {
m := mouseTestModel()
m.mouseCapture = true
m.transcript = appendRow(m.transcript, rowUser, "hidden behind wizard")
textX, textY := firstTranscriptTextMousePoint(t, m)
m.mcpAddWizard = newMCPAddWizard("http")
m.mcpAddWizard.step = mcpAddWizardStepType
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, textX, textY))
next := updated.(model)
if cmd != nil {
t.Fatal("click behind MCP add wizard should not return a command")
}
if next.transcriptSelection.active {
t.Fatal("MCP add wizard should block transcript selection behind the overlay")
}
}
func TestTranscriptCopyStatusUsesComposerSpacerWithoutFooterGrowth(t *testing.T) {
func TestTranscriptCopyStatusRendersAboveComposer(t *testing.T) {
m := mouseTestModel()
m.providerName = "ollama-cloud"
m.modelName = "qwen3-coder:480b"
normalFooterLines := len(viewLines(plainRender(t, m.footerView(80))))
normalViewLines := len(viewLines(plainRender(t, m.View())))
m.copyStatus = "Copied!"
footer := plainRender(t, m.footerView(80))
if got := len(viewLines(footer)); got != normalFooterLines {
t.Fatalf("copy status changed footer height from %d to %d:\n%s", normalFooterLines, got, footer)
}
view := plainRender(t, m.View())
if got := len(viewLines(view)); got != normalViewLines {
t.Fatalf("copy status changed view height from %d to %d:\n%s", normalViewLines, got, view)
}
if !strings.Contains(view, "Copied!") {
t.Fatalf("view should show copy status, got:\n%s", view)
}
footerLines := viewLines(footer)
if len(footerLines) < 2 || !strings.Contains(footerLines[0], "Copied!") || !strings.HasPrefix(footerLines[1], "╭") {
t.Fatalf("copy status should replace the spacer directly above composer, got:\n%s", footer)
lines := strings.Split(footer, "\n")
if len(lines) < 2 || !strings.Contains(lines[1], "Copied!") {
t.Fatalf("footer should show copy status above composer, got:\n%s", footer)
}
if strings.Contains(plainRender(t, m.statusLine(80)), "Copied!") {
t.Fatalf("status line should not contain copy feedback: %q", plainRender(t, m.statusLine(80)))
@@ -555,26 +603,6 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
}
}
func firstTranscriptTextMouseY(t *testing.T, m model) int {
t.Helper()
_, y := firstTranscriptTextMousePoint(t, m)
return y
}
func firstTranscriptTextMousePoint(t *testing.T, m model) (int, int) {
t.Helper()
width := chatWidth(m.width)
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
for _, line := range selectable {
if line.text != "" && !line.toggle {
return line.textStart, top + line.bodyY - start
}
}
t.Fatalf("no selectable transcript text line found: %#v", selectable)
return 0, 0
}
func mouseTestModel() model {
m := newModel(context.Background(), Options{})
m.width = 100
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more