mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1705d3260e | ||
|
|
6df5c2b4cb | ||
|
|
b7f90a787f | ||
|
|
b1cf24831d | ||
|
|
0b496ce87d | ||
|
|
724167e90e | ||
|
|
6092303e73 | ||
|
|
1e25fde3b1 | ||
|
|
7fae5c530f | ||
|
|
7af2a1b3b6 | ||
|
|
9c1cc69b59 | ||
|
|
a0b9876b69 | ||
|
|
c38444157c | ||
|
|
75ecf8e385 | ||
|
|
efb6b79164 | ||
|
|
2bcb85bfb3 | ||
|
|
e1253c826c |
No files matched your search
@@ -1,16 +1,311 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
description: Project-level instruction set for the AI agent working in this repo. Ten discrete modes covering the recurring engineering workflows on Zero.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# AGENTS.md
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
Instructions for the AI agent working inside the `zero` repository. Read this before any non-trivial change.
|
||||
|
||||
## 1. Repository map
|
||||
|
||||
### Entry points
|
||||
|
||||
- `cmd/zero` — primary CLI binary. Run with `go run ./cmd/zero`.
|
||||
- `cmd/zero-release` — release tooling. `build`, `smoke`, `package`, `verify`. Never bypass.
|
||||
- `cmd/zero-seccomp` — Linux seccomp profile generator for the sandbox.
|
||||
- `cmd/zero-perf-bench` — performance smoke work.
|
||||
- `cmd/zero-pr-review` — local PR-review runner.
|
||||
- `bin/zero.js` — npm wrapper around the Go binary. Touch only when adjusting the npm surface.
|
||||
|
||||
### Core internal packages
|
||||
|
||||
- `agent/` — the agent loop. Prompt in, tool calls out, results back, repeat. `guardrails.go` holds the loop's stop and confirmation thresholds. `loop.go` is the main state machine.
|
||||
- `tui/` — terminal UI built on `charm.land/bubbletea/v2` and `charm.land/lipgloss/v2`. `model.go` holds state, `view.go` is pure render, `command_center.go` dispatches slash commands, `theme.go` owns every color and border (hex literals are forbidden elsewhere).
|
||||
- `providers/` — LLM adapters. One subdir per provider: `openai/`, `anthropic/`, `gemini/`, `providerio/`. `factory.go` registers them.
|
||||
- `sandbox/` — exec gating and risk classification. `risk.go` defines patterns, `engine.go` decides allow/deny, `paths.go` and `pathlists.go` handle allow/deny lists.
|
||||
- `sessions/` — session state, persistence, resume, title generation.
|
||||
- `specialist/` — sub-agent routing. Specialists run scoped tasks with their own tool whitelist. `builtin.go` declares the interface, `runtime.go` runs them, `manifest.go` and `exec.go` are the execution paths.
|
||||
- `swarm/` — multi-agent coordination for batched work.
|
||||
- `mcp/` — Model Context Protocol client and tool bridge.
|
||||
- `modelregistry/` — model catalog, capability flags (vision, tool calls, reasoning efforts), cost data. `catalog.go` is `DefaultModelEntries`.
|
||||
- `providercatalog/` — provider descriptors (base URLs, auth env vars, default model).
|
||||
- `tools/` — agent-callable tools. Each tool implements the `Tool` interface in `tools/types.go`. `registry.go` is the central map; `CoreToolsScoped` / `CoreReadOnlyToolsScoped` / `CoreWriteToolsScoped` / `CoreShellToolsScoped` / `CoreNetworkTools` are the registration sites.
|
||||
- `cli/` — top-level command wiring. `app.go` is the entry, `command_center.go` dispatches subcommands, per-feature files (`exec.go`, `sessions.go`, `setup.go`, etc.) implement each subcommand.
|
||||
|
||||
Other packages worth knowing: `config/`, `redaction/`, `secrets/`, `imageinput/`, `cron/`, `doctor/`, `browser/`, `background/`, `lsp/`, `hooks/`, `update/`, `usage/`, `verify/`, `testrunner/`, `selfverify/`, `workspaceindex/`, `worktrees/`, `workspaceseed/`, `specmode/`, `streamjson/`, `notify/`, `keyring/`, `observability/`, `oauth/`, `provideroauth/`, `provideronboarding/`, `providerhealth/`, `providermodelcatalog/`, `providermodeldiscovery/`, `npmwrapper/`, `agenteval/`, `zerocommands/`, `zerogit/`, `zeroruntime/`, `release/`, `review/`, `repomap/`, `repoinfo/`, `search/`, `skills/`, `plugins/`.
|
||||
|
||||
### Test surface
|
||||
|
||||
- Tests live next to the file they cover (`foo.go` → `foo_test.go`). No separate `tests/` tree.
|
||||
- Snapshot tests use `charmbracelet/x/exp/golden` (visible in `go.sum`).
|
||||
- `go test ./internal/<pkg>/ -run TestX -v` for narrow runs; `go test ./...` for the full suite.
|
||||
- No `-race` on Windows (no gcc toolchain). On Linux/macOS, `-race` is fine.
|
||||
|
||||
## 2. Conventions
|
||||
|
||||
- Go 1.25, toolchain `go1.26.4`. Charm libs are pinned to v2 (`charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`).
|
||||
- Shell parsing uses `mvdan.cc/sh/v3`, not `os/exec` + string concatenation. Never invent your own shell tokenizer.
|
||||
- `gofmt -l <files>` and `go vet ./...` must pass before commit. CI enforces both.
|
||||
- The npm wrapper at `bin/zero.js` is the only Node-touching surface; everything else is Go-native. Do not add Bun or TypeScript build deps.
|
||||
- Theme tokens are the single source of truth for color and border. Hex literals anywhere except `internal/tui/theme.go` are rejected.
|
||||
- The TUI split is strict: `model.go` owns state, `view.go` is pure render. Styling calls (`Style.Render`, `NewStyle`) live in `view.go` and render helpers under `tui/`; `model.go` may call `lipgloss.Width` for column measurements but never applies styles.
|
||||
- Errors are wrapped with `fmt.Errorf("...: %w", err)` and surface context. Never swallow errors silently.
|
||||
- Public API changes require a one-line note in the PR body explaining the impact and the migration path.
|
||||
|
||||
## 3. Ten agent modes
|
||||
|
||||
Each mode is a discrete workflow the AI agent adopts when the task matches. Pick the mode at the start of the task; do not blend them.
|
||||
|
||||
### Mode 1 — Add a slash command
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/commands.go` — add a `commandDefinition` entry. Set `name`, `aliases`, `usage`, `group`, `description`, `kind` (one of the `commandKind` constants in this file).
|
||||
- `internal/tui/command_center.go` — add a `case command<Name>:` branch to the slash-command dispatch. Match the dispatch shape used by existing commands: read `command.text`, mutate model state, append a transcript row.
|
||||
- `internal/tui/<feature>.go` — co-locate the handler with the related feature. Handler signature is `(model, args) (model, string)` where the string is the status text the transcript displays.
|
||||
- `internal/tui/view.go` or `internal/tui/command_output.go` — only if the command renders a card or panel; reuse the existing `commandCard` and `commandOutput` helpers.
|
||||
|
||||
Pattern to follow: copy `commandEffort` end-to-end (definition in `commands.go`, dispatch in `command_center.go`, handler in `session_controls.go`). Do not invent a new shape.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not register the command in two places. One definition, one dispatch, one handler.
|
||||
- Do not read input from `m.input.Value()` inside the handler. The dispatch already passes `command.text`.
|
||||
- Do not mutate `m.picker` from inside a slash-command handler. Use `m.openXxxPicker()` if a picker is needed.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go build ./cmd/zero
|
||||
go test ./internal/tui/ -run Test<Command> -v
|
||||
```
|
||||
|
||||
### Mode 2 — Add a tool
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tools/<name>.go` — implement the `Tool` interface from `internal/tools/types.go`. Required methods: `Name()`, `Description()`, `Parameters()`, `Safety()`, `Run(ctx, args) Result`. Fill `Safety{ SideEffect, Permission, Reason }` honestly.
|
||||
- `internal/tools/registry.go` — register the tool by adding it to the appropriate `CoreToolsScoped` variant (read-only, write, shell, network, or full). The factory functions decide which tool set a context gets.
|
||||
- `internal/tools/<name>_test.go` — table-driven test covering the happy path, each error path declared in the schema, and one permission-boundary case.
|
||||
|
||||
Pattern to follow: copy `internal/tools/read_file.go` end-to-end. It demonstrates the `Tool` interface, the schema pattern, and the test layout.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not call `os/exec` directly. The bash tool is the only path; everything else routes through `mvdan.cc/sh/v3` indirectly or uses Go stdlib.
|
||||
- Do not mutate `m.transcript` from inside a tool. Tools return `Result`; the agent loop appends to the transcript.
|
||||
- Do not skip `Safety`. Every tool must declare `SideEffect` and `Permission`.
|
||||
- Do not add new top-level deps. If a tool needs a library, justify it in the PR body.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/tools/ -run Test<Name> -v
|
||||
go vet ./internal/tools/
|
||||
```
|
||||
|
||||
### Mode 3 — Add a provider
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/providers/<name>/` — new subdir. Mirror `internal/providers/openai/` (factory wiring, request types, response mapping, image / tool-call handling, tests).
|
||||
- `internal/providers/factory.go` — register the new provider in the factory switch.
|
||||
- `internal/providercatalog/catalog.go` — add a `Descriptor` entry (ID, display name, default base URL, auth env vars, default model, capabilities).
|
||||
- `internal/cli/...` — only if the provider needs a new flag. Mirror existing flags before adding new ones.
|
||||
|
||||
Pattern to follow: copy `internal/providers/openai/`. It has factory wiring, request shaping, content-part conversion, image blocks, and tests for every code path.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not skip the image / vision path. Even if your first model doesn't support vision, the provider must handle `zeroruntime.ImageBlock` correctly (Anthropic `image` block, OpenAI `image_url`, Gemini `inlineData`).
|
||||
- Do not invent your own HTTP client. Reuse the one in `internal/providers/openai/` or `internal/providerio/`.
|
||||
- Do not hardcode model IDs. They live in `internal/modelregistry/`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/providers/<name>/ -v
|
||||
go test ./internal/providers/ -v
|
||||
go test ./internal/providercatalog/ -v
|
||||
```
|
||||
|
||||
### Mode 4 — Add a model entry
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/modelregistry/catalog.go` — extend `DefaultModelEntries()` with the new model. Fields: ID, Provider, DisplayName, ContextWindow, Capabilities (`vision`, `tool_call`, `reasoning`), ReasoningEfforts (if applicable), Cost.
|
||||
|
||||
Pattern to follow: open `catalog.go`, find the existing entry for the same provider, copy the row, edit the values. Same shape, same fields.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not invent capabilities. Set only the ones the model actually supports. If unsure, leave the capability unset.
|
||||
- Do not add cost data you cannot cite. If you don't have a source, leave the cost fields zero and note it in the PR.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/modelregistry/ -v
|
||||
go test ./internal/tui/ -run TestModel -v
|
||||
```
|
||||
|
||||
### Mode 5 — Add a specialist
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/specialist/<name>.go` — implement the specialist. Read `internal/specialist/builtin.go` for the interface and `runtime.go` for the execution model.
|
||||
- `internal/specialist/registry.go` — register the specialist. Update the routing table so the main agent knows when to dispatch to it.
|
||||
|
||||
Pattern to follow: copy `internal/specialist/exec.go` (or another existing specialist). The shape is consistent.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not give a specialist access to tools the main agent doesn't. Specialists have their own whitelist.
|
||||
- Do not let a specialist call another specialist. One level of dispatch only.
|
||||
- Do not register a specialist without a routing rule. Routing without a rule is unreachable code.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/specialist/ -v
|
||||
go test ./internal/agent/ -v
|
||||
```
|
||||
|
||||
### Mode 6 — Add a sandbox rule
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/sandbox/risk.go` — add a pattern to the appropriate risk bucket (`destructive`, `destructive_catastrophic`, `network`, `credential`, etc.).
|
||||
- `internal/sandbox/engine.go` — only if the rule needs a new decision branch.
|
||||
- `internal/sandbox/risk_hardening_test.go` (or a sibling test file) — write a failing test FIRST that exercises the new pattern.
|
||||
|
||||
Pattern to follow: read `risk.go` to see the existing pattern classification. The `add()` helper takes `(pattern, RiskLevel)`. New rules sit alongside their category.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not weaken an existing rule. If you find a false positive, write a more specific pattern that excludes the safe case rather than removing the dangerous one.
|
||||
- Do not add a rule without a test. Sandbox regressions are silent and dangerous.
|
||||
- Do not skip `requestPaths` checks. When a command takes paths, the sandbox must validate them against the workspace + grant scope.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/sandbox/ -run TestRisk -v
|
||||
```
|
||||
|
||||
### Mode 7 — Modify the TUI
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/model.go` — state only. Add fields, add cases to `Update()`, add dispatch logic. Never apply lipgloss styles here.
|
||||
- `internal/tui/view.go` — pure render. Read from `model`, render to styled strings. Every style application lives here or in a `render*` helper.
|
||||
- `internal/tui/<feature>.go` — split when the feature is large. Look at the existing per-feature files (`picker.go`, `session.go`, `transcript_body.go`, etc.) for the convention.
|
||||
- `internal/tui/theme.go` — only if you need a new color or border. Add a named token with a hex value; never inline a hex elsewhere.
|
||||
|
||||
Pattern to follow: open `model.go` and `view.go` side-by-side. Find the feature you're extending. The state mutation lives in `model.go`; the renderer lives in `view.go`. Tests live in `*_test.go` next to each.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not apply lipgloss styles from `model.go`.
|
||||
- Do not store styled strings in the model. Store raw values; render at draw time.
|
||||
- Do not bypass `m.transcript`. Every visible row goes through `appendTranscriptRow`.
|
||||
- Do not add a hex color literal anywhere except `theme.go`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
gofmt -l internal/tui/
|
||||
go vet ./internal/tui/
|
||||
go test ./internal/tui/ -count=1
|
||||
```
|
||||
|
||||
### Mode 8 — Debug a failing test
|
||||
|
||||
Steps:
|
||||
|
||||
1. Run narrow: `go test ./internal/<pkg>/ -run Test<Name> -v -count=1`. Read the failure verbatim — the assertion message is the most reliable signal.
|
||||
2. Read the test. The test is the contract. Do not rewrite the test to match broken behavior.
|
||||
3. Read the production code under test. Find the divergence between what the test expects and what the code does.
|
||||
4. Fix the production code. If the production code is right and the test was wrong, fix the test — but only after proving the production behavior is correct.
|
||||
5. Run narrow again. Then run the package. Then run the adjacent packages. Then run `go test ./...` to catch regressions.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not `t.Skip()` to make a test pass. Skip with a TODO comment and a linked issue at most.
|
||||
- Do not delete a failing test without understanding why it was added.
|
||||
- Do not change a test's expected value without a commit message explaining why.
|
||||
- Do not modify `time.Now()` or any clock source to make a timing-sensitive test pass.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/<pkg>/ -run Test<Name> -v -count=1
|
||||
go test ./internal/<pkg>/ -count=1
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Mode 9 — Cut a release
|
||||
|
||||
Touch files:
|
||||
|
||||
- `cmd/zero-release` only. No manual `git tag`. No manual binary upload. No edits to `CHANGELOG.md` by hand.
|
||||
|
||||
Pattern to follow: run `go run ./cmd/zero-release build`, then `go run ./cmd/zero-release smoke`, then `go run ./cmd/zero-release package`, then `go run ./cmd/zero-release verify`. The release tooling is the contract.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not push a tag by hand. The release tooling owns tag creation.
|
||||
- Do not skip `smoke`. If smoke fails, the release fails.
|
||||
- Do not edit `CHANGELOG.md` manually. The release tooling generates it from `git log` since the previous tag.
|
||||
- Do not bypass `verify`. Verification is the last gate.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
```
|
||||
|
||||
### Mode 10 — Run a security or quality audit
|
||||
|
||||
Touch files (read-only by default; edits only after explicit user approval):
|
||||
|
||||
- `internal/sandbox/` — destructive-command classification in `risk.go`, decision logic in `engine.go`, path allow/deny in `paths.go` and `pathlists.go`. Audit coverage: do destructive paths actually get caught? Are grant scopes respected? Are `..` traversals blocked?
|
||||
- `internal/secrets/` — env handling. Audit: are secrets redacted in logs? Are `os.Getenv` calls going through the keyring when configured?
|
||||
- `internal/redaction/` — output redaction. Audit: is the redaction list current? Are there new tool outputs that need redaction entries?
|
||||
- `internal/providers/` — provider auth. Audit: are API keys read from env, never persisted? Are provider adapters validating their inputs?
|
||||
- `internal/mcp/` — MCP server trust. Audit: are MCP server outputs treated as data, never as instructions? Is the schema validated?
|
||||
- `internal/hooks/` — pre/post command hooks. Audit: do hooks have a way to abort? Is the abort path tested?
|
||||
- `docs/audit/` — read the most recent `*-deep-audit*.md` and `*-reverification.md` to see what's already been reviewed. Do not re-audit a category already covered.
|
||||
|
||||
Pattern to follow: pick one subsystem per audit pass. Read the code, run the existing tests, write a one-page report naming each finding with a `file:line` anchor and an exploitability classification (`exploitable | plausible | theoretical`).
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not audit and edit in the same pass. Audit first, get user sign-off, then fix.
|
||||
- Do not propose fixes that disable a security control to silence a false positive. Tighten the rule instead.
|
||||
- Do not skip the audit because a recent audit covered the area. Confirm the diff since the last audit is empty.
|
||||
- Do not write findings without a `file:line` anchor. Vague findings get deleted.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/secrets/ -v
|
||||
go test ./internal/redaction/ -v
|
||||
go test ./internal/mcp/ -v
|
||||
```
|
||||
|
||||
## 4. Hard rules across all modes
|
||||
|
||||
- Never run `rm -rf`, `git reset --hard`, or force-push without an explicit user message.
|
||||
- Never edit a file you have not read in full first.
|
||||
- Never widen `Permission` from `prompt` to `allow` for a tool without a test that proves the wider permission is safe.
|
||||
- Never delete a test. The test is a contract.
|
||||
- Never bypass `go vet` or `gofmt` with a `// nolint:` comment unless the PR body explains why.
|
||||
- Never commit secrets, even temporarily. If a secret lands in a commit, revert the commit and rotate the secret.
|
||||
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
@@ -1,37 +1,36 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
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
|
||||
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/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.38.0
|
||||
mvdan.cc/sh/v3 v3.11.0
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // 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/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/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/text v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -1,31 +1,33 @@
|
||||
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-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/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/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
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/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/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=
|
||||
@@ -34,33 +36,23 @@ 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.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/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/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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/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-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=
|
||||
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=
|
||||
@@ -90,11 +90,48 @@ 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 "Agent stopped after " + strconv.Itoa(turns) +
|
||||
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
|
||||
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
|
||||
}
|
||||
|
||||
// Reminder markers are stable substrings used both to build the reminder text
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,17 @@ 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
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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) {
|
||||
|
||||
@@ -186,10 +186,6 @@ 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{}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -105,7 +105,10 @@ func defaultAppDeps() appDeps {
|
||||
return config.ResolveMCP(options)
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return providers.New(profile, providers.Options{UserAgent: userAgent()})
|
||||
return providers.New(profile, providers.Options{
|
||||
UserAgent: userAgent(),
|
||||
OAuthResolver: oauthResolverForProfile(profile),
|
||||
})
|
||||
},
|
||||
probeProviderHealth: providerhealth.Probe,
|
||||
newSessionStore: func() *sessions.Store {
|
||||
@@ -429,6 +432,18 @@ 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 {
|
||||
|
||||
+63
-4
@@ -5,10 +5,12 @@ 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"
|
||||
)
|
||||
|
||||
@@ -34,11 +36,44 @@ 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
|
||||
@@ -124,9 +159,23 @@ 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.
|
||||
// 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).
|
||||
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
|
||||
// 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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -323,9 +372,13 @@ 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 you configure via env (no providers are
|
||||
built in). For a provider named <name>, set:
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
@@ -333,6 +386,12 @@ built in). For a 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)
|
||||
|
||||
@@ -17,6 +17,20 @@ 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
|
||||
@@ -116,6 +130,21 @@ 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 {
|
||||
|
||||
+90
-5
@@ -38,6 +38,8 @@ 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:
|
||||
@@ -56,14 +58,20 @@ 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>
|
||||
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f> [--bundle-dir <d>]
|
||||
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).
|
||||
(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.
|
||||
|
||||
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.
|
||||
local socket. Use the link's remote path as --cwd to run against a linked repo.
|
||||
`)
|
||||
return code
|
||||
}
|
||||
@@ -399,7 +407,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 := "", "", ""
|
||||
addr, certFile, keyFile, bundleDir := "", "", "", ""
|
||||
minVersion, maxConns := 0, 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
@@ -449,6 +457,14 @@ 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))
|
||||
}
|
||||
@@ -491,7 +507,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, Log: logf,
|
||||
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
@@ -524,6 +540,75 @@ 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 {
|
||||
|
||||
@@ -367,6 +367,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
|
||||
FocusMode: notify.FocusAlways,
|
||||
})
|
||||
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely
|
||||
// log a failed delivery to stderr (never stdout). The sink redacts before
|
||||
// logging, so a token in the URL or message is masked.
|
||||
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
|
||||
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
|
||||
})
|
||||
if options.useSpec {
|
||||
return runExecSpecDraft(execSpecDraftRun{
|
||||
options: options,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+104
-2
@@ -3,11 +3,13 @@ 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"
|
||||
@@ -285,8 +287,108 @@ func setupRequired(resolved config.ResolvedConfig) bool {
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return true
|
||||
}
|
||||
_, missing := setupMissingCredentialEnv(resolved.Provider)
|
||||
return missing
|
||||
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
|
||||
}
|
||||
|
||||
func formatSetupComplete(result tui.SetupResult) string {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,43 @@ 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 == "" {
|
||||
|
||||
@@ -150,6 +150,72 @@ 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")
|
||||
|
||||
@@ -96,12 +96,23 @@ 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.
|
||||
|
||||
@@ -16,6 +16,9 @@ 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
|
||||
@@ -27,6 +30,8 @@ type Bridge struct {
|
||||
minVersion int
|
||||
handshakeTimeout time.Duration
|
||||
authFailDelay time.Duration
|
||||
bundleDir string
|
||||
maxBundleBytes int64
|
||||
log func(string)
|
||||
sem chan struct{}
|
||||
|
||||
@@ -51,7 +56,13 @@ type BridgeOptions struct {
|
||||
HandshakeTimeout time.Duration
|
||||
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
|
||||
AuthFailDelay time.Duration
|
||||
Log func(string)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// NewBridge validates options and builds a Bridge.
|
||||
@@ -85,6 +96,10 @@ 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,
|
||||
@@ -92,6 +107,8 @@ 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
|
||||
@@ -179,13 +196,37 @@ 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 for a long time.
|
||||
// Clear the handshake deadline: a session may stream, and a bundle upload may
|
||||
// transfer many megabytes, for a long time.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// deny rejects an unauthenticated connection after a small backoff (to slow
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,23 @@ 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")
|
||||
@@ -65,7 +82,7 @@ func DialRemote(cfg RemoteConfig) (*daemon.Client, 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}); err != nil {
|
||||
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion, Mode: mode}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: send auth: %w", err)
|
||||
}
|
||||
@@ -79,7 +96,7 @@ func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return daemon.NewClientConn(conn)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// clientTLSConfig builds a verifying client TLS config. It never disables
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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
|
||||
@@ -28,10 +30,11 @@ 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 792] /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 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
|
||||
|
||||
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
|
||||
content := minimalPDFTextContent(text, pageHeight-92)
|
||||
startObj() // object 4: content stream
|
||||
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
|
||||
buf.WriteString(content)
|
||||
@@ -52,6 +55,56 @@ 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")
|
||||
@@ -159,7 +212,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+4096 {
|
||||
for body.Len() < MaxDocumentTextBytes+65536 {
|
||||
body.WriteString(line)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
|
||||
@@ -173,7 +226,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.Fatal("Truncated should be set when extracted text is capped")
|
||||
t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages)
|
||||
}
|
||||
if !strings.Contains(doc.Text, documentTruncatedMarker) {
|
||||
t.Fatal("truncated text should carry the truncation marker")
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+132
-173
@@ -6,17 +6,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenStoreSchemaVersion = 1
|
||||
tokenStoreLockTimeout = 5 * time.Second
|
||||
tokenStoreLockRetry = 10 * time.Millisecond
|
||||
)
|
||||
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
|
||||
// retained so migration can recognize a file it understands.
|
||||
const tokenStoreSchemaVersion = 1
|
||||
|
||||
// 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
|
||||
@@ -42,29 +40,37 @@ type TokenStatus struct {
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
type TokenStoreOptions struct {
|
||||
FilePath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
FilePath string
|
||||
LegacyPath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// TokenStore persists OAuth tokens per MCP server in a 0600 JSON file.
|
||||
// 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.
|
||||
type TokenStore struct {
|
||||
filePath string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
store *oauth.Store
|
||||
}
|
||||
|
||||
// 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 for OAuth tokens,
|
||||
// honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
|
||||
// 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.
|
||||
func ResolveTokenStorePath(env map[string]string) (string, error) {
|
||||
override := strings.TrimSpace(envValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"))
|
||||
if override != "" {
|
||||
@@ -95,215 +101,168 @@ func ResolveTokenStorePath(env map[string]string) (string, error) {
|
||||
return filepath.Join(configHome, "zero", "mcp-oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewTokenStore builds a file-backed token store.
|
||||
// NewTokenStore builds the unified-store-backed token store and runs a one-time
|
||||
// migration from a legacy file when applicable.
|
||||
func NewTokenStore(options TokenStoreOptions) (*TokenStore, error) {
|
||||
filePath := options.FilePath
|
||||
var err error
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath, err = ResolveTokenStorePath(options.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &TokenStore{filePath: filepath.Clean(filePath), now: now}, nil
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if legacyPath != "" {
|
||||
if err := store.migrateLegacy(legacyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// FilePath returns the resolved token store path.
|
||||
// FilePath returns the resolved unified store path.
|
||||
func (store *TokenStore) FilePath() string {
|
||||
return store.filePath
|
||||
return store.store.FilePath()
|
||||
}
|
||||
|
||||
// Save persists the token for a server, replacing any existing entry.
|
||||
func (store *TokenStore) Save(serverName string, token StoredToken) error {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
unlock, err := store.lockStateFile()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Tokens[serverName] = token
|
||||
return store.writeState(state)
|
||||
return store.store.Save(key, storedToOAuth(token))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
token, ok := state.Tokens[serverName]
|
||||
return token, ok, nil
|
||||
token, ok, err := store.store.Load(key)
|
||||
if err != nil || !ok {
|
||||
return StoredToken{}, ok, err
|
||||
}
|
||||
return tokenToStored(token), true, 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) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
unlock, err := store.lockStateFile()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
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
|
||||
return store.store.Delete(key)
|
||||
}
|
||||
|
||||
// Status returns a redaction-safe summary of every stored token, sorted by
|
||||
// Status returns a redaction-safe summary of every stored MCP token, sorted by
|
||||
// server name. It never includes the token material.
|
||||
func (store *TokenStore) Status() ([]TokenStatus, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(state.Tokens))
|
||||
for name := range state.Tokens {
|
||||
names = append(names, name)
|
||||
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,
|
||||
})
|
||||
}
|
||||
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
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (store *TokenStore) readState() (tokenFile, error) {
|
||||
data, err := os.ReadFile(store.filePath)
|
||||
// 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)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return emptyTokenFile(), nil
|
||||
return 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
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if 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
|
||||
}
|
||||
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
|
||||
if legacy.SchemaVersion != tokenStoreSchemaVersion {
|
||||
return nil // unknown legacy schema: leave it untouched
|
||||
}
|
||||
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)
|
||||
for serverName, token := range legacy.Tokens {
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
continue // a name that cannot form a valid unified key is skipped
|
||||
}
|
||||
if locked {
|
||||
return func() {
|
||||
_ = unlockPermissionFile(file)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
if _, ok, loadErr := store.store.Load(key); loadErr == nil && ok {
|
||||
continue // a unified entry already exists; never overwrite
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("timed out waiting for MCP OAuth token lock at %s", lockPath)
|
||||
if err := store.store.Save(key, storedToOAuth(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(tokenStoreLockRetry)
|
||||
}
|
||||
return os.Rename(legacyPath, legacyPath+".migrated")
|
||||
}
|
||||
|
||||
func emptyTokenFile() tokenFile {
|
||||
return tokenFile{
|
||||
SchemaVersion: tokenStoreSchemaVersion,
|
||||
Tokens: map[string]StoredToken{},
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,101 @@ 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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package notify
|
||||
|
||||
import "strings"
|
||||
|
||||
// Webhook delivery is configured entirely from the environment. A webhook URL
|
||||
// typically embeds a secret token, so sourcing it from the environment keeps it
|
||||
// out of any on-disk config file. The sink is strictly opt-in: with
|
||||
// EnvWebhookURL unset the wiring helper attaches nothing and the notifier
|
||||
// behaves exactly as before.
|
||||
const (
|
||||
// EnvWebhookURL holds the destination webhook/Slack URL. Empty disables it.
|
||||
EnvWebhookURL = "ZERO_NOTIFY_WEBHOOK_URL"
|
||||
// EnvWebhookSummary is an optional one-line run summary attached to every
|
||||
// payload (for example "nightly audit run").
|
||||
EnvWebhookSummary = "ZERO_NOTIFY_WEBHOOK_SUMMARY"
|
||||
)
|
||||
|
||||
// MaybeAddWebhookSink attaches a webhook sink to n when a webhook URL is present
|
||||
// in the environment, and is otherwise a no-op. env resolves an environment
|
||||
// variable (pass os.Getenv); logf records one redacted line per failed delivery
|
||||
// (pass nil to stay silent — for example a TUI that owns the screen). It is safe
|
||||
// to call unconditionally: configuration alone decides whether the sink exists.
|
||||
//
|
||||
// The attached sink is still subject to the notifier's Mode/focus policy, so a
|
||||
// webhook only delivers when notifications are enabled (for example
|
||||
// `--notify both`), matching the rest of the notification surface.
|
||||
func MaybeAddWebhookSink(n *Notifier, env func(string) string, logf func(format string, args ...any)) {
|
||||
if n == nil || env == nil {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(env(EnvWebhookURL))
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
n.AddSink(NewWebhookSink(WebhookConfig{
|
||||
URL: url,
|
||||
Summary: strings.TrimSpace(env(EnvWebhookSummary)),
|
||||
Logf: logf,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// envFunc builds a deterministic env resolver from a map for the wiring tests.
|
||||
func envFunc(values map[string]string) func(string) string {
|
||||
return func(key string) string { return values[key] }
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkAttachesAndDelivers(t *testing.T) {
|
||||
var hits int32
|
||||
var gotSummary string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
var payload webhookPayload
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload)
|
||||
gotSummary = payload.Summary
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
MaybeAddWebhookSink(n, envFunc(map[string]string{
|
||||
EnvWebhookURL: server.URL,
|
||||
EnvWebhookSummary: "nightly audit",
|
||||
}), nil)
|
||||
|
||||
if got := len(n.sinks); got != 1 {
|
||||
t.Fatalf("expected 1 sink attached, got %d", got)
|
||||
}
|
||||
|
||||
n.Notify(Completion, "Zero: ready")
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("webhook hit %d times, want 1", got)
|
||||
}
|
||||
if gotSummary != "nightly audit" {
|
||||
t.Fatalf("summary = %q, want %q", gotSummary, "nightly audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkNoopWhenURLBlank(t *testing.T) {
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
|
||||
// Unset.
|
||||
MaybeAddWebhookSink(n, envFunc(nil), nil)
|
||||
// Set but blank / whitespace only.
|
||||
MaybeAddWebhookSink(n, envFunc(map[string]string{EnvWebhookURL: " "}), nil)
|
||||
|
||||
if got := len(n.sinks); got != 0 {
|
||||
t.Fatalf("expected no sink attached, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkNilGuards(t *testing.T) {
|
||||
// Must not panic on a nil notifier or nil env resolver.
|
||||
MaybeAddWebhookSink(nil, envFunc(map[string]string{EnvWebhookURL: "https://example.test"}), nil)
|
||||
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
MaybeAddWebhookSink(n, nil, nil)
|
||||
if got := len(n.sinks); got != 0 {
|
||||
t.Fatalf("nil env must attach nothing, got %d sinks", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+98
-10
@@ -2,6 +2,7 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -9,6 +10,11 @@ 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
|
||||
|
||||
@@ -126,6 +132,62 @@ 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) {
|
||||
@@ -214,20 +276,31 @@ 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, cfg, err := m.loadForKey(key)
|
||||
token, err := m.loadToken(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, cfg, err := m.loadForKey(key)
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := m.resolveConfigForKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -245,27 +318,42 @@ func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, cu
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
|
||||
// loadForKey loads a stored token and resolves the provider config for its key.
|
||||
func (m *Manager) loadForKey(key string) (Token, Config, error) {
|
||||
// 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) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, Config{}, err
|
||||
return Token{}, err
|
||||
}
|
||||
token, ok, err := m.store.Load(key)
|
||||
if err != nil {
|
||||
return Token{}, Config{}, err
|
||||
return Token{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Token{}, Config{}, fmt.Errorf("oauth: no stored token for %q", key)
|
||||
return Token{}, fmt.Errorf("%w for %q", ErrNoToken, 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 Token{}, Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
|
||||
return 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 Token{}, Config{}, err
|
||||
return Config{}, err
|
||||
}
|
||||
return token, cfg, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
// Logout removes a provider's stored token, reporting whether one was present.
|
||||
|
||||
@@ -106,6 +106,76 @@ 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{
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
// - 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 any overridable
|
||||
// endpoints come from config/env, so no third-party OAuth client identity is
|
||||
// baked into the binary.
|
||||
// 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.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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...)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+30
-13
@@ -28,10 +28,11 @@ func ValidateProviderName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
type Registry struct{}
|
||||
|
||||
// NewRegistry returns the (stateless) env-driven registry.
|
||||
@@ -61,21 +62,37 @@ 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: 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"))),
|
||||
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),
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)", name, envKey(name, "CLIENT_ID"))
|
||||
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)
|
||||
}
|
||||
var flow Flow
|
||||
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
|
||||
case "", string(FlowLoopback):
|
||||
case "":
|
||||
flow = preset.Flow
|
||||
if flow == "" {
|
||||
flow = FlowLoopback
|
||||
}
|
||||
case string(FlowLoopback):
|
||||
flow = FlowLoopback
|
||||
case string(FlowDevice):
|
||||
flow = FlowDevice
|
||||
|
||||
@@ -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.loadForKey(key)
|
||||
token, err := m.loadToken(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
|
||||
|
||||
+237
-56
@@ -1,16 +1,20 @@
|
||||
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 (
|
||||
@@ -54,14 +58,41 @@ 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
|
||||
}
|
||||
|
||||
// Store persists OAuth tokens (provider + MCP namespaces) in a 0600 JSON file,
|
||||
// guarded by a cross-process lock and written atomically.
|
||||
// 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.
|
||||
type Store struct {
|
||||
filePath string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
blob blobStore
|
||||
crypter *aesGCMCrypter // nil => plaintext blob
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type storeFile struct {
|
||||
@@ -99,29 +130,80 @@ func ResolveStorePath(env map[string]string) (string, error) {
|
||||
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewStore builds a file-backed token store.
|
||||
// NewStore builds a token store with the configured backend (file by default,
|
||||
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
|
||||
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 nil, err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Store{filePath: filepath.Clean(filePath), now: now}, nil
|
||||
return filepath.Clean(filePath), 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 {
|
||||
@@ -129,17 +211,14 @@ func (s *Store) Save(key string, token Token) error {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// Load returns the token for key; the bool is false when none is stored.
|
||||
@@ -164,23 +243,20 @@ func (s *Store) Delete(key string) (bool, error) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
||||
@@ -218,54 +294,159 @@ func (s *Store) Status(prefix string) ([]Status, error) {
|
||||
}
|
||||
|
||||
func (s *Store) readState() (storeFile, error) {
|
||||
data, err := os.ReadFile(s.filePath)
|
||||
data, ok, err := s.blob.read()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return emptyStoreFile(), 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)
|
||||
}
|
||||
}
|
||||
var state storeFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
if state.SchemaVersion != storeSchemaVersion {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: unsupported schemaVersion", s.filePath)
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: unsupported schemaVersion", s.blob.location())
|
||||
}
|
||||
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 file at %s: %w", s.filePath, err)
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), 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
|
||||
}
|
||||
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 {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, s.filePath); err != nil {
|
||||
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 {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func emptyStoreFile() storeFile {
|
||||
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
|
||||
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 (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 {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,17 @@ 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 {
|
||||
@@ -77,7 +88,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"),
|
||||
openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
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"}),
|
||||
@@ -89,13 +100,20 @@ 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),
|
||||
openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}),
|
||||
oauthProvider(openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}), false, true),
|
||||
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"),
|
||||
}
|
||||
@@ -229,6 +247,28 @@ 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
|
||||
|
||||
@@ -33,6 +33,7 @@ var expectedCatalogIDs = []string{
|
||||
"zai",
|
||||
"gitlawb-opengateway",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
"custom-anthropic-compatible",
|
||||
}
|
||||
@@ -220,7 +221,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", "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", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
@@ -277,6 +278,19 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,10 @@ 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
|
||||
@@ -104,6 +108,7 @@ type Provider struct {
|
||||
authScheme string
|
||||
authHeaderValue string
|
||||
customHeaders map[string]string
|
||||
oauthResolver providerio.TokenResolver
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
@@ -142,6 +147,7 @@ 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,
|
||||
@@ -176,24 +182,26 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
|
||||
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{
|
||||
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body,
|
||||
providerio.AuthHeaders{
|
||||
APIKey: provider.apiKey,
|
||||
DefaultAuthHeader: "x-api-key",
|
||||
AuthHeader: provider.authHeader,
|
||||
AuthScheme: provider.authScheme,
|
||||
AuthHeaderValue: provider.authHeaderValue,
|
||||
CustomHeaders: provider.customHeaders,
|
||||
})
|
||||
}, 0)
|
||||
},
|
||||
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)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,10 @@ 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.
|
||||
@@ -37,6 +42,7 @@ 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,
|
||||
@@ -51,6 +57,7 @@ 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,
|
||||
|
||||
@@ -19,10 +19,6 @@ 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).
|
||||
|
||||
@@ -34,6 +34,10 @@ 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
|
||||
@@ -54,6 +58,7 @@ type Provider struct {
|
||||
authScheme string
|
||||
authHeaderValue string
|
||||
customHeaders map[string]string
|
||||
oauthResolver providerio.TokenResolver
|
||||
maxTokens int
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
@@ -100,6 +105,7 @@ 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,
|
||||
@@ -139,12 +145,8 @@ 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.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{
|
||||
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, endpoint, body,
|
||||
providerio.AuthHeaders{
|
||||
APIKey: provider.apiKey,
|
||||
DefaultAuthHeader: "Authorization",
|
||||
DefaultAuthScheme: "Bearer",
|
||||
@@ -152,8 +154,14 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
AuthScheme: provider.authScheme,
|
||||
AuthHeaderValue: provider.authHeaderValue,
|
||||
CustomHeaders: provider.customHeaders,
|
||||
})
|
||||
}, 0)
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
}, 0)
|
||||
if err != nil {
|
||||
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,42 @@ 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)
|
||||
|
||||
@@ -378,6 +378,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+24
-5
@@ -64,10 +64,11 @@ 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
|
||||
idSeq atomic.Uint64
|
||||
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
|
||||
}
|
||||
|
||||
// Team is a named set of concurrently-running members with a bounded slot count
|
||||
@@ -136,13 +137,31 @@ func New(opts Options) (*Swarm, error) {
|
||||
}
|
||||
|
||||
// Close cancels every running member's context and releases resources. It is
|
||||
// safe to call more than once.
|
||||
// safe to call more than once. The scheduler is closed first so no new spawn
|
||||
// fires after shutdown begins.
|
||||
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()
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
|
||||
@@ -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} {
|
||||
for _, name := range []string{SpawnToolName, SendToolName, InboxToolName, StatusToolName, HandoffToolName, CollectToolName, ScheduleToolName} {
|
||||
if _, ok := reg.Get(name); !ok {
|
||||
t.Fatalf("tool %q not registered", name)
|
||||
}
|
||||
|
||||
@@ -217,10 +217,15 @@ func CoreShellToolsScoped(workspaceRoot string, scope PathScope) []Tool {
|
||||
}
|
||||
|
||||
func CoreNetworkTools() []Tool {
|
||||
return []Tool{
|
||||
NewWebFetchTool(),
|
||||
NewWebSearchTool(),
|
||||
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 tools
|
||||
}
|
||||
|
||||
func CoreTools(workspaceRoot string) []Tool { return CoreToolsScoped(workspaceRoot, nil) }
|
||||
|
||||
@@ -47,6 +47,8 @@ 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
|
||||
@@ -76,6 +78,22 @@ 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 {
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -160,7 +162,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: " + err.Error())
|
||||
return errorResult("Error: " + redactWebSearchText(err.Error()))
|
||||
}
|
||||
results = filtered
|
||||
}
|
||||
@@ -175,9 +177,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 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.
|
||||
// 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".
|
||||
func formatSearchResults(results []searchResult) string {
|
||||
lines := make([]string, 0, len(results)*2)
|
||||
for index, result := range results {
|
||||
@@ -186,8 +188,11 @@ func formatSearchResults(results []searchResult) string {
|
||||
title = "(untitled)"
|
||||
}
|
||||
header := fmt.Sprintf("%d. %s — %s", index+1, title, strings.TrimSpace(result.URL))
|
||||
if result.Score > 0 {
|
||||
header += fmt.Sprintf(" — score %.2f", result.Score)
|
||||
// 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)
|
||||
}
|
||||
lines = append(lines, header)
|
||||
if snippet := strings.TrimSpace(result.Snippet); snippet != "" {
|
||||
@@ -296,16 +301,39 @@ 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 float64 `json:"score,omitempty"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score lenientScore `json:"score,omitempty"`
|
||||
}
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
@@ -319,7 +347,7 @@ func parseSearchResults(body []byte) ([]searchResult, error) {
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Snippet,
|
||||
Score: item.Score,
|
||||
Score: float64(item.Score),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -421,6 +449,10 @@ 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 ""
|
||||
@@ -461,6 +493,7 @@ 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
|
||||
}
|
||||
@@ -103,6 +103,8 @@ 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" {
|
||||
@@ -110,7 +112,7 @@ func TestWebSearchRegisteredInCoreNetworkTools(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("web_search should be registered in CoreNetworkTools()")
|
||||
t.Fatal("web_search should be registered in CoreNetworkTools() when a backend is configured")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,3 +588,122 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ = next.Update(testKey(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,7 +5,7 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -666,10 +666,6 @@ func renderMarkdownStandaloneLine(line string) string {
|
||||
return strings.TrimRight(line, " ")
|
||||
}
|
||||
|
||||
func stripMarkdownInline(text string) string {
|
||||
return markdownInlinePlain(text)
|
||||
}
|
||||
|
||||
type markdownInlineSegment struct {
|
||||
text string
|
||||
bold bool
|
||||
|
||||
@@ -333,16 +333,6 @@ 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
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
updated, _ := m.Update(testKeyText(string(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(tea.KeyMsg{Type: tea.KeyTab})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyTab})
|
||||
updated, _ = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyDown})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyUp})
|
||||
updated, _ = m.Update(testKey(tea.KeyUp))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
updated, _ = m.Update(testKey(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(tea.MouseMsg{Button: tea.MouseButtonWheelDown})
|
||||
updated, _ := m.Update(testMouseWheel(tea.MouseWheelDown, 0, 0))
|
||||
m = updated.(model)
|
||||
if m.suggestionIdx != 1 {
|
||||
t.Fatalf("wheel down should select index 1, got %d", m.suggestionIdx)
|
||||
}
|
||||
|
||||
updated, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp})
|
||||
updated, _ = m.Update(testMouseWheel(tea.MouseWheelUp, 0, 0))
|
||||
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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("fix")})
|
||||
updated, cmd = m.Update(testKeyText("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(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, cmd = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||
updated, cmd = m.Update(testKeyText("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(tea.KeyMsg{Type: tea.KeyTab})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyDown})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
updated, _ = m.Update(testKey(tea.KeySpace))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
|
||||
updated, _ = m.Update(testKeyText("@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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
updated, _ = m.Update(testKey(tea.KeySpace))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
|
||||
updated, _ = m.Update(testKeyText("@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(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
updated, _ = m.Update(testKey(tea.KeySpace))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("@guide")})
|
||||
updated, _ = m.Update(testKeyText("@guide"))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, _ = m.Update(testKey(tea.KeyBackspace))
|
||||
m = updated.(model)
|
||||
|
||||
if got := m.composerValue(); got != paste+" " {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// bashEscapeTimeout bounds a "!cmd" shell escape so a hung command can't freeze
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/doctor"
|
||||
@@ -229,17 +229,20 @@ func (m model) searchText(query string) string {
|
||||
return zsearch.FormatResult(zsearch.RedactResult(result))
|
||||
}
|
||||
|
||||
func (m model) resumeText(args string) string {
|
||||
args = strings.TrimSpace(args)
|
||||
if args != "" {
|
||||
// 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 {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Sessions",
|
||||
Status: commandStatusInfo,
|
||||
Status: commandStatusBlocked,
|
||||
Sections: []commandSection{{
|
||||
Title: "Resume",
|
||||
Lines: []string{"requested session: " + args},
|
||||
Title: "Store",
|
||||
Lines: []string{"session store unavailable"},
|
||||
}},
|
||||
Hints: []string{"use /resume " + args + " to hydrate this TUI session"},
|
||||
})
|
||||
}
|
||||
// Only standalone conversations — not child/spec sub-runs, which an agent
|
||||
@@ -343,12 +346,6 @@ 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
|
||||
@@ -430,6 +427,7 @@ 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
|
||||
@@ -457,15 +455,39 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
lines = append(lines, target.notice)
|
||||
}
|
||||
lines = append(lines,
|
||||
"Switched model for this TUI session.",
|
||||
"Switched model.",
|
||||
"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
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
commandPlan
|
||||
commandSearch
|
||||
commandResume
|
||||
commandRetitle
|
||||
commandSpec
|
||||
commandCompact
|
||||
commandRewind
|
||||
@@ -160,6 +161,13 @@ 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>",
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("custom remote")})
|
||||
updated, cmd = next.Update(testKeyText("custom remote"))
|
||||
next = updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("expected MCP manager search to update synchronously")
|
||||
}
|
||||
|
||||
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("playwright")})
|
||||
updated, cmd := next.Update(testKeyText("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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("play")})
|
||||
updated, _ = next.Update(testKeyText("play"))
|
||||
next = updated.(model)
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyDelete})
|
||||
updated, cmd := next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("context7")})
|
||||
updated, _ = next.Update(testKeyText("context7"))
|
||||
next = updated.(model)
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Alt: true})
|
||||
updated, cmd := next.Update(testKeyAltText("a"))
|
||||
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.KeyMsg{
|
||||
{Type: tea.KeyRunes, Runes: []rune("adds")},
|
||||
{Type: tea.KeyEnter},
|
||||
{Type: tea.KeyEnter},
|
||||
{Type: tea.KeyRunes, Runes: []rune("sxas")},
|
||||
{Type: tea.KeyEnter},
|
||||
for _, key := range []tea.Msg{
|
||||
testKeyText("adds"),
|
||||
testKey(tea.KeyEnter),
|
||||
testKey(tea.KeyEnter),
|
||||
testKeyText("sxas"),
|
||||
testKey(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.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")},
|
||||
for index, key := range []tea.Msg{
|
||||
testKeyText("draft"),
|
||||
testKey(tea.KeyEnter),
|
||||
testKey(tea.KeyEnter),
|
||||
testKeyText("sxas"),
|
||||
testKey(tea.KeyEnter),
|
||||
testKeyText("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.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},
|
||||
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),
|
||||
} {
|
||||
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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatal("expected MCP setup intent to open synchronously")
|
||||
}
|
||||
|
||||
updated, cmd = next.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.mcpManager == nil {
|
||||
t.Fatal("expected MCP manager to open")
|
||||
}
|
||||
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d"), Alt: true})
|
||||
updated, cmd := next.Update(testKeyAltText("d"))
|
||||
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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd == nil {
|
||||
|
||||
+41
-56
@@ -7,8 +7,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type composerState struct {
|
||||
@@ -72,16 +72,6 @@ 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)
|
||||
@@ -188,84 +178,70 @@ func composerDisplayCursor(state composerState) int {
|
||||
func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
|
||||
state := m.currentComposerState()
|
||||
switch {
|
||||
case msg.Type == tea.KeyEnter && msg.Alt:
|
||||
case keyIs(msg, tea.KeyEnter) && keyAlt(msg):
|
||||
m = m.insertComposerTextWithPastePreview(state, "\n", "")
|
||||
case msg.Type == tea.KeyCtrlJ:
|
||||
case keyCtrl(msg, 'j'):
|
||||
m = m.insertComposerTextWithPastePreview(state, "\n", "")
|
||||
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "d":
|
||||
case keyAlt(msg) && keyText(msg) == "d":
|
||||
end := deleteComposerWordAfter(state).cursor
|
||||
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
case (msg.Type == tea.KeyLeft || msg.Type == tea.KeyCtrlLeft) && msg.Alt:
|
||||
case keyAlt(msg) && keyIs(msg, tea.KeyLeft):
|
||||
m.setComposerState(moveComposerWordBefore(state))
|
||||
case (msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlRight) && msg.Alt:
|
||||
case keyAlt(msg) && keyIs(msg, tea.KeyRight):
|
||||
m.setComposerState(moveComposerWordAfter(state))
|
||||
case msg.Type == tea.KeyCtrlLeft:
|
||||
case keyCtrlArrow(msg, tea.KeyLeft):
|
||||
m.setComposerState(moveComposerWordBefore(state))
|
||||
case msg.Type == tea.KeyCtrlRight:
|
||||
case keyCtrlArrow(msg, tea.KeyRight):
|
||||
m.setComposerState(moveComposerWordAfter(state))
|
||||
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "b":
|
||||
case keyAlt(msg) && keyText(msg) == "b":
|
||||
m.setComposerState(moveComposerWordBefore(state))
|
||||
case msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == "f":
|
||||
case keyAlt(msg) && keyText(msg) == "f":
|
||||
m.setComposerState(moveComposerWordAfter(state))
|
||||
case msg.Type == tea.KeySpace:
|
||||
case keyIs(msg, tea.KeySpace):
|
||||
m = m.insertComposerTextWithPastePreview(state, " ", "")
|
||||
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:
|
||||
case keyPrintable(msg):
|
||||
m = m.applyComposerText(state, keyText(msg), false)
|
||||
case keyIs(msg, tea.KeyLeft) || keyCtrl(msg, 'b'):
|
||||
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, -1); ok {
|
||||
m.setComposerState(nextState)
|
||||
break
|
||||
}
|
||||
state.cursor--
|
||||
m.setComposerState(state)
|
||||
case msg.Type == tea.KeyRight || msg.Type == tea.KeyCtrlF:
|
||||
case keyIs(msg, tea.KeyRight) || keyCtrl(msg, 'f'):
|
||||
if nextState, ok := moveComposerPastePreviewBoundary(state, m.composerPastePreviews, 1); ok {
|
||||
m.setComposerState(nextState)
|
||||
break
|
||||
}
|
||||
state.cursor++
|
||||
m.setComposerState(state)
|
||||
case msg.Type == tea.KeyHome || msg.Type == tea.KeyCtrlA:
|
||||
case keyIs(msg, tea.KeyHome) || keyCtrl(msg, 'a'):
|
||||
state.cursor = composerLineStart(state)
|
||||
m.setComposerState(state)
|
||||
case msg.Type == tea.KeyEnd || msg.Type == tea.KeyCtrlE:
|
||||
case keyIs(msg, tea.KeyEnd) || keyCtrl(msg, 'e'):
|
||||
state.cursor = composerLineEnd(state)
|
||||
m.setComposerState(state)
|
||||
case msg.Type == tea.KeyCtrlU:
|
||||
case keyCtrl(msg, 'u'):
|
||||
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, composerLineStart(state), state.cursor)
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
case msg.Type == tea.KeyCtrlK:
|
||||
case keyCtrl(msg, 'k'):
|
||||
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, composerLineEnd(state))
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
case msg.Type == tea.KeyCtrlW || (msg.Alt && (msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH)):
|
||||
case keyCtrl(msg, 'w') || (keyAlt(msg) && keyBackspace(msg)):
|
||||
start := deleteComposerWordBefore(state).cursor
|
||||
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, start, state.cursor)
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
case msg.Alt && msg.Type == tea.KeyDelete:
|
||||
case keyAlt(msg) && keyIs(msg, tea.KeyDelete):
|
||||
end := deleteComposerWordAfter(state).cursor
|
||||
nextState, nextPreviews := deleteComposerRangeWithPastePreviews(state, m.composerPastePreviews, state.cursor, end)
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
case msg.Type == tea.KeyBackspace || msg.Type == tea.KeyCtrlH:
|
||||
case keyBackspace(msg):
|
||||
if nextState, nextPreviews, ok := deleteComposerPastePreviewBefore(state, m.composerPastePreviews); ok && !m.suggestionsActive() {
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
@@ -278,7 +254,7 @@ func (m model) applyComposerKey(msg tea.KeyMsg) (model, bool) {
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
}
|
||||
case msg.Type == tea.KeyDelete || msg.Type == tea.KeyCtrlD:
|
||||
case keyIs(msg, tea.KeyDelete) || keyCtrl(msg, 'd'):
|
||||
if nextState, nextPreviews, ok := deleteComposerPastePreviewAfter(state, m.composerPastePreviews); ok {
|
||||
m.setComposerState(nextState)
|
||||
m.composerPastePreviews = nextPreviews
|
||||
@@ -299,6 +275,23 @@ 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
|
||||
@@ -580,14 +573,6 @@ 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)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
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(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyCtrlV})
|
||||
updated, cmd := m.Update(testKeyCtrl('v'))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -154,7 +154,7 @@ func TestPastedMultilineComposerContentRendersAsPreview(t *testing.T) {
|
||||
}, "\n")
|
||||
m := newModel(context.Background(), Options{})
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
next := updated.(model)
|
||||
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, _ = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
|
||||
updated, _ = next.Update(testKeyAlt(tea.KeyBackspace))
|
||||
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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(paste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(paste))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
||||
updated, _ = next.Update(testKeyText("x"))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
updated, _ = next.Update(testKey(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(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(firstPaste), Paste: true})
|
||||
updated, _ := m.Update(testPaste(firstPaste))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyCtrlJ})
|
||||
updated, _ = next.Update(testKeyCtrl('j'))
|
||||
next = updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(secondPaste), Paste: true})
|
||||
updated, _ = next.Update(testPaste(secondPaste))
|
||||
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.KeyMsg
|
||||
key tea.Msg
|
||||
}{
|
||||
{name: "alt enter", key: tea.KeyMsg{Type: tea.KeyEnter, Alt: true}},
|
||||
{name: "shift enter", key: tea.KeyMsg{Type: tea.KeyCtrlJ}},
|
||||
{name: "alt enter", key: testKeyAlt(tea.KeyEnter)},
|
||||
{name: "shift enter", key: testKeyCtrl('j')},
|
||||
}
|
||||
|
||||
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(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
updated, _ := m.Update(testKeyCtrl('u'))
|
||||
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(tea.KeyMsg{Type: tea.KeyCtrlK})
|
||||
updated, _ = next.Update(testKeyCtrl('k'))
|
||||
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(tea.KeyMsg{Type: tea.KeySpace})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyUp})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyDown})
|
||||
updated, _ = next.Update(testKey(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.KeyMsg
|
||||
key tea.Msg
|
||||
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: tea.KeyMsg{Type: tea.KeyBackspace, Alt: true},
|
||||
key: testKeyAlt(tea.KeyBackspace),
|
||||
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: tea.KeyMsg{Type: tea.KeyCtrlW},
|
||||
key: testKeyCtrl('w'),
|
||||
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: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true},
|
||||
key: testKeyAltText("b"),
|
||||
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: tea.KeyMsg{Type: tea.KeyCtrlLeft},
|
||||
key: testKeyPressMod(tea.KeyLeft, tea.ModCtrl),
|
||||
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: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true},
|
||||
key: testKeyAltText("f"),
|
||||
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: tea.KeyMsg{Type: tea.KeyCtrlRight},
|
||||
key: testKeyPressMod(tea.KeyRight, tea.ModCtrl),
|
||||
want: "alpha beta gamma",
|
||||
wantCursor: len([]rune("alpha beta")),
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected /doctor fix to run provider connectivity asynchronously")
|
||||
|
||||
@@ -3,7 +3,7 @@ package tui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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 := next.View()
|
||||
view := viewString(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 := next.View()
|
||||
view := viewString(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
t.Fatalf("expected /clear to reset transcript, got %#v", next.transcript)
|
||||
@@ -120,7 +120,7 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
|
||||
m.runCancel = func() {}
|
||||
m.streamingText = "half an answer"
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
m = updated.(model)
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
updated, _ := m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyUp})
|
||||
updated, _ = m.Update(testKey(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(tea.KeyMsg{Type: tea.KeyDown})
|
||||
updated, _ = m.Update(testKey(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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
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 msg.Type {
|
||||
case tea.KeyEsc:
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
m.cancelMCPCommand()
|
||||
m.mcpAddWizard = nil
|
||||
return m, nil
|
||||
case tea.KeyBackspace, tea.KeyCtrlH:
|
||||
case keyBackspace(msg):
|
||||
wizard.deleteRune()
|
||||
return m, nil
|
||||
case tea.KeyCtrlU:
|
||||
case keyCtrl(msg, 'u'):
|
||||
wizard.clearCurrentInput()
|
||||
return m, nil
|
||||
case tea.KeyLeft:
|
||||
case keyIs(msg, tea.KeyLeft):
|
||||
wizard.back()
|
||||
return m, nil
|
||||
case tea.KeyUp:
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult {
|
||||
wizard.move(-1)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyDown, tea.KeyTab:
|
||||
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
if wizard.step == mcpAddWizardStepType || wizard.step == mcpAddWizardStepResult {
|
||||
wizard.move(1)
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyRunes:
|
||||
case keyText(msg) != "":
|
||||
if wizard.step == mcpAddWizardStepResult {
|
||||
return m.handleMCPAddWizardResultShortcut(msg)
|
||||
}
|
||||
wizard.appendRunes(msg.Runes)
|
||||
wizard.appendRunes(keyRunes(msg))
|
||||
return m, nil
|
||||
case tea.KeyEnter, tea.KeyRight:
|
||||
case keyIs(msg, tea.KeyEnter) || keyIs(msg, 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(string(msg.Runes)) {
|
||||
switch strings.ToLower(keyText(msg)) {
|
||||
case "e", "r":
|
||||
m.mcpAddWizard.step = mcpAddWizardStepEndpoint
|
||||
m.mcpAddWizard.err = ""
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
)
|
||||
|
||||
+13
-13
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,26 +109,26 @@ func (m model) handleMCPManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
|
||||
if m.mcpManager == nil {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
switch {
|
||||
case keyIs(msg, tea.KeyEsc):
|
||||
m.mcpManager = nil
|
||||
case tea.KeyUp:
|
||||
case keyIs(msg, tea.KeyUp):
|
||||
m.moveMCPManager(-1)
|
||||
case tea.KeyDown, tea.KeyTab:
|
||||
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
|
||||
m.moveMCPManager(1)
|
||||
case tea.KeyEnter:
|
||||
case keyIs(msg, tea.KeyEnter):
|
||||
return m.chooseMCPManagerItem()
|
||||
case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlH:
|
||||
case keyBackspace(msg) || keyIs(msg, tea.KeyDelete):
|
||||
m.deleteMCPManagerQueryRune()
|
||||
case tea.KeyCtrlU:
|
||||
case keyCtrl(msg, 'u'):
|
||||
m.mcpManager.query = ""
|
||||
m.mcpManager.selected = 0
|
||||
case tea.KeyRunes:
|
||||
if !msg.Alt {
|
||||
m.appendMCPManagerQuery(msg.Runes)
|
||||
case keyText(msg) != "":
|
||||
if !keyAlt(msg) {
|
||||
m.appendMCPManagerQuery(keyRunes(msg))
|
||||
return m, nil
|
||||
}
|
||||
switch strings.ToLower(string(msg.Runes)) {
|
||||
switch strings.ToLower(keyText(msg)) {
|
||||
case "a":
|
||||
return m.openMCPAddWizard("http"), nil
|
||||
case "s":
|
||||
|
||||
@@ -113,19 +113,6 @@ 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)
|
||||
@@ -146,31 +133,6 @@ 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 {
|
||||
@@ -380,18 +342,6 @@ 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")
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func TestMCPViewRendersEmptyState(t *testing.T) {
|
||||
|
||||
+568
-117
File diff suppressed because it is too large.
Load diff
+321
-60
@@ -5,12 +5,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -76,7 +77,7 @@ func TestPromptSubmitInjectsLiveSessionModelContext(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("which model are you")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
@@ -136,7 +137,7 @@ func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) {
|
||||
}
|
||||
m.input.SetValue("hello")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected prompt submit to start an agent run")
|
||||
@@ -247,19 +248,19 @@ func TestTranscriptReducer(t *testing.T) {
|
||||
|
||||
func TestInitialRenderShowsLimeChatSurface(t *testing.T) {
|
||||
model := newModel(context.Background(), Options{
|
||||
Cwd: `D:\codings\Opensource\Zero`,
|
||||
Cwd: `/workspace/zero`,
|
||||
ProviderName: "openai",
|
||||
ModelName: "gpt-4.1",
|
||||
})
|
||||
model.width = 120
|
||||
model.height = 34
|
||||
|
||||
view := model.View()
|
||||
assertContains(t, view, " 0 ")
|
||||
assertContains(t, view, "zero")
|
||||
view := viewString(model.View())
|
||||
assertContains(t, view, `/workspace/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") {
|
||||
@@ -273,7 +274,7 @@ func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
|
||||
m.height = 30
|
||||
m.input.SetValue("inspect the repo")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
next.width = m.width
|
||||
next.height = m.height
|
||||
@@ -287,7 +288,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 := next.View()
|
||||
view := viewString(next.View())
|
||||
if strings.Contains(view, emptyStateTagline) {
|
||||
t.Fatalf("empty state should collapse after first prompt, got %q", view)
|
||||
}
|
||||
@@ -302,12 +303,12 @@ func TestEmptyStateStaysVisibleOnEmptySubmit(t *testing.T) {
|
||||
m.height = 30
|
||||
m.input.SetValue(" ")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
next.width = m.width
|
||||
next.height = m.height
|
||||
|
||||
view := next.View()
|
||||
view := viewString(next.View())
|
||||
assertContains(t, view, emptyStateTagline)
|
||||
assertNotContains(t, view, "❯ inspect")
|
||||
}
|
||||
@@ -316,7 +317,7 @@ func TestHelpCommandAppendsHelpRow(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/help")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "/tools") {
|
||||
@@ -332,7 +333,7 @@ func TestClearCommandResetsTranscript(t *testing.T) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
|
||||
m.input.SetValue("/clear")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if len(next.transcript) != 1 || next.transcript[0].kind != rowWelcome {
|
||||
@@ -346,7 +347,7 @@ func TestToolsCommandListsRegisteredTools(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Registry: registry})
|
||||
m.input.SetValue("/tools")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "read_file") {
|
||||
@@ -380,7 +381,7 @@ func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/permissions")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -428,7 +429,7 @@ func TestPlanCommandShowsCurrentPlan(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Registry: registry})
|
||||
m.input.SetValue("/plan")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -445,7 +446,7 @@ func TestPlanCommandHandlesMissingPlanTool(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Registry: tools.NewRegistry()})
|
||||
m.input.SetValue("/plan")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "No plan is active") {
|
||||
@@ -465,7 +466,7 @@ func TestContextCommandShowsSessionState(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/context")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -494,7 +495,7 @@ func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/model list")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -534,7 +535,7 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/model gpt-4.1-mini")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -553,6 +554,57 @@ 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
|
||||
@@ -613,7 +665,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(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -660,7 +712,7 @@ func TestModelCommandRequiresProviderRebuildForSwitch(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/model gpt-4.1-mini")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -687,7 +739,7 @@ func TestModelCommandRejectsSwitchWhilePending(t *testing.T) {
|
||||
m.pending = true
|
||||
m.input.SetValue("/model gpt-4.1-mini")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -716,7 +768,7 @@ func TestModelCommandReportsProviderRebuildErrors(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/model gpt-4.1-mini")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.modelName != "gpt-4.1" {
|
||||
@@ -738,7 +790,7 @@ func TestDoctorCommandUsesCurrentProviderProfile(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("/doctor")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -774,7 +826,7 @@ func TestSearchCommandUsesSessionStore(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.input.SetValue("/search needle")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -789,7 +841,7 @@ func TestSearchCommandRequiresQuery(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/search")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "usage: /search <query>") {
|
||||
@@ -816,21 +868,176 @@ func TestResumeCommandListsRecentSessions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: store})
|
||||
m.input.SetValue("/resume")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /resume to be handled without starting an agent run")
|
||||
}
|
||||
if !transcriptContains(next.transcript, "Newer") || !transcriptContains(next.transcript, "Older") {
|
||||
t.Fatalf("expected session list in transcript, got %#v", next.transcript)
|
||||
// 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)
|
||||
}
|
||||
// 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("sessions card view missing %q:\n%s", want, view)
|
||||
// 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} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("session picker 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -838,7 +1045,7 @@ func TestResumeCommandWithUnknownIDReportsMissingSession(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
|
||||
m.input.SetValue("/resume zero_123")
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if !transcriptContains(next.transcript, "zero session not found: zero_123") {
|
||||
@@ -859,7 +1066,7 @@ func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) {
|
||||
})
|
||||
m.input.SetValue("say hi")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if !transcriptContains(next.transcript, "say hi") {
|
||||
t.Fatalf("expected user row after submit, got %#v", next.transcript)
|
||||
@@ -884,7 +1091,7 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
|
||||
m.pending = true
|
||||
m.input.SetValue("second prompt")
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -905,7 +1112,7 @@ func TestEscCancelsPendingRun(t *testing.T) {
|
||||
m.activeRunID = 1
|
||||
m.runCancel = func() { cancelled = true }
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
updated, _ := m.Update(testKey(tea.KeyEsc))
|
||||
next := updated.(model)
|
||||
|
||||
if !cancelled {
|
||||
@@ -1028,7 +1235,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", "[a] allow", "[d] deny", "[y] always", "risk:high", "Creates or overwrites files."} {
|
||||
for _, want := range []string{"write_file", "allow once", "[a]", "deny", "[d]", "always", "[y]", "risk:high", "Creates or overwrites files."} {
|
||||
assertContains(t, view, want)
|
||||
}
|
||||
}
|
||||
@@ -1059,7 +1266,7 @@ func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
|
||||
})
|
||||
next := updated.(model)
|
||||
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tc.key)})
|
||||
updated, cmd := next.Update(testKeyText(tc.key))
|
||||
next = updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
@@ -1090,20 +1297,22 @@ func TestPermissionPromptBlocksNormalSubmit(t *testing.T) {
|
||||
next := updated.(model)
|
||||
next.input.SetValue("second prompt")
|
||||
|
||||
updated, cmd := next.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, cmd := next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected Enter to be ignored while permission prompt is active")
|
||||
t.Fatal("expected permission confirm to resolve synchronously (no cmd)")
|
||||
}
|
||||
if len(decisions) != 0 {
|
||||
t.Fatalf("expected Enter not to choose a permission decision, got %#v", decisions)
|
||||
// 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 transcriptContains(next.transcript, "second prompt") {
|
||||
t.Fatalf("permission prompt should block normal prompt submit, got %#v", next.transcript)
|
||||
}
|
||||
if next.pendingPermission == nil {
|
||||
t.Fatal("expected permission prompt to remain pending after Enter")
|
||||
if next.pendingPermission != nil {
|
||||
t.Fatalf("expected permission prompt to clear after confirm, got %#v", next.pendingPermission)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,7 +1483,7 @@ func TestShiftTabCyclesPermissionMode(t *testing.T) {
|
||||
agent.PermissionModeAsk,
|
||||
agent.PermissionModeAuto,
|
||||
} {
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
updated, cmd := m.Update(testKeyShift(tea.KeyTab))
|
||||
m = updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatalf("expected shift+tab to cycle mode synchronously, got command")
|
||||
@@ -1303,7 +1512,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
|
||||
m.activeRunID = 7
|
||||
updated, _ := m.Update(permissionRequestMsg{runID: 7, request: testPromptPermissionRequest()})
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
updated, _ = next.Update(testKeyShift(tea.KeyTab))
|
||||
next = updated.(model)
|
||||
if next.permissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("expected mode unchanged while permission modal is up, got %q", next.permissionMode)
|
||||
@@ -1318,7 +1527,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(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
updated, _ = next.Update(testKeyShift(tea.KeyTab))
|
||||
next = updated.(model)
|
||||
if next.permissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("expected mode unchanged while ask_user prompt is up, got %q", next.permissionMode)
|
||||
@@ -1335,12 +1544,12 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
|
||||
PermissionMode: agent.PermissionModeAuto,
|
||||
})
|
||||
m.input.SetValue("/model")
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if next.picker == nil {
|
||||
t.Skip("model picker unavailable in test environment")
|
||||
}
|
||||
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||||
updated, _ = next.Update(testKeyShift(tea.KeyTab))
|
||||
next = updated.(model)
|
||||
if next.permissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("expected mode unchanged while picker is open, got %q", next.permissionMode)
|
||||
@@ -1351,7 +1560,7 @@ func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
|
||||
func TestCtrlCExits(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
|
||||
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
updated, cmd := m.Update(testKeyCtrl('c'))
|
||||
next := updated.(model)
|
||||
|
||||
if !next.exiting {
|
||||
@@ -1362,19 +1571,21 @@ func TestCtrlCExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, text string, want string) {
|
||||
func assertContains(t *testing.T, text any, want string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("expected %q to contain %q", text, want)
|
||||
content := plainRender(t, text)
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected %q to contain %q", content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotContains(t *testing.T, text string, unwanted string) {
|
||||
func assertNotContains(t *testing.T, text any, unwanted string) {
|
||||
t.Helper()
|
||||
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("expected %q not to contain %q", text, unwanted)
|
||||
content := plainRender(t, text)
|
||||
if strings.Contains(content, unwanted) {
|
||||
t.Fatalf("expected %q not to contain %q", content, unwanted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1522,3 +1733,53 @@ 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)
|
||||
}
|
||||
}
|
||||
+42
-75
@@ -1,8 +1,9 @@
|
||||
package tui
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type mouseOverlayHit struct {
|
||||
x int
|
||||
y int
|
||||
}
|
||||
|
||||
@@ -14,6 +15,20 @@ 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:
|
||||
@@ -164,34 +179,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.mouseCapture = want
|
||||
if want {
|
||||
return m, tea.EnableMouseCellMotion
|
||||
}
|
||||
return m, tea.DisableMouse
|
||||
}
|
||||
|
||||
// Mouse classification uses the current Button/Action pair only. Bubble Tea's
|
||||
// parser always populates Button+Action and merely derives the deprecated Type
|
||||
// field from them, so checking Type adds nothing — and a left-button drag is
|
||||
// Action==Motion (which mouseMotion already covers), not a press.
|
||||
func mouseLeftPress(msg tea.MouseMsg) bool {
|
||||
return msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress
|
||||
}
|
||||
|
||||
func mouseMotion(msg tea.MouseMsg) bool {
|
||||
return msg.Action == tea.MouseActionMotion
|
||||
}
|
||||
|
||||
func mouseRelease(msg tea.MouseMsg) bool {
|
||||
return msg.Action == tea.MouseActionRelease
|
||||
}
|
||||
|
||||
func mouseWheelUp(msg tea.MouseMsg) bool {
|
||||
return msg.Button == tea.MouseButtonWheelUp
|
||||
}
|
||||
|
||||
func mouseWheelDown(msg tea.MouseMsg) bool {
|
||||
return msg.Button == tea.MouseButtonWheelDown
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
|
||||
@@ -199,33 +187,8 @@ func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
|
||||
return false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
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
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
return frame.composerRect.contains(mouseX(msg), mouseY(msg))
|
||||
}
|
||||
|
||||
func lineSequenceIndex(lines []string, sequence []string) int {
|
||||
@@ -421,7 +384,7 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
|
||||
}
|
||||
|
||||
func (m *model) selectProviderWizardAtMouse(msg tea.MouseMsg) (mouseSelectionTarget, bool) {
|
||||
if m.providerWizard == nil {
|
||||
if m.providerWizard == nil || m.providerWizard.oauthPending {
|
||||
return mouseSelectionTarget{}, false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
@@ -487,35 +450,39 @@ func (m model) overlayMouseHit(msg tea.MouseMsg, overlay string, width int) (mou
|
||||
if overlayWidth <= 0 || len(lines) == 0 {
|
||||
return mouseOverlayHit{}, false
|
||||
}
|
||||
top := m.overlayMouseTop(len(lines), width)
|
||||
if msg.Y < top || msg.Y >= top+len(lines) {
|
||||
rect := m.overlayMouseRect(len(lines), width)
|
||||
if rect.height <= 0 || mouseY(msg) < rect.y || mouseY(msg) >= rect.y+rect.height {
|
||||
return mouseOverlayHit{}, false
|
||||
}
|
||||
if msg.X < left || msg.X >= left+overlayWidth {
|
||||
if mouseX(msg) < left || mouseX(msg) >= left+overlayWidth {
|
||||
return mouseOverlayHit{}, false
|
||||
}
|
||||
return mouseOverlayHit{y: msg.Y - top}, true
|
||||
return mouseOverlayHit{x: mouseX(msg) - left, y: mouseY(msg) - rect.y}, 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 0
|
||||
return tuiRect{}
|
||||
}
|
||||
if m.altScreen && m.height > 0 {
|
||||
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)
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
visibleHeight := minInt(overlayHeight, frame.bodyRect.height)
|
||||
if visibleHeight <= 0 {
|
||||
return tuiRect{}
|
||||
}
|
||||
available := m.height - len(viewLines(m.footerView(width)))
|
||||
if available < 1 {
|
||||
available = 1
|
||||
return tuiRect{
|
||||
y: frame.bodyRect.y + maxInt(0, (frame.bodyRect.height-visibleHeight)/2),
|
||||
width: width,
|
||||
height: visibleHeight,
|
||||
}
|
||||
return maxInt(0, (available-overlayHeight)/2)
|
||||
}
|
||||
return maxInt(0, (normalizedStartupHeight(m.height)-overlayHeight)/2)
|
||||
return tuiRect{
|
||||
y: maxInt(0, (normalizedStartupHeight(m.height)-overlayHeight)/2),
|
||||
width: width,
|
||||
height: overlayHeight,
|
||||
}
|
||||
}
|
||||
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user