mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc34b855b5 | ||
|
|
6df5c2b4cb | ||
|
|
b7f90a787f | ||
|
|
b1cf24831d | ||
|
|
0b496ce87d | ||
|
|
724167e90e | ||
|
|
6092303e73 | ||
|
|
1e25fde3b1 | ||
|
|
7fae5c530f | ||
|
|
7af2a1b3b6 | ||
|
|
9c1cc69b59 | ||
|
|
a0b9876b69 | ||
|
|
c38444157c | ||
|
|
75ecf8e385 | ||
|
|
efb6b79164 | ||
|
|
2bcb85bfb3 | ||
|
|
e1253c826c | ||
|
|
f857c4afb4 | ||
|
|
bb7de29c80 | ||
|
|
de5d402e1f |
No files matched your search
@@ -0,0 +1,875 @@
|
||||
# Awesome Agent Instructions for Zero
|
||||
|
||||
A curated set of self-contained agent configurations — custom droids, slash commands, and AGENTS.md blocks — that wire up specialist workflows on top of Zero. Ten entries, picked from a wider pool of about a hundred candidates.
|
||||
|
||||
## Why this list
|
||||
|
||||
Zero ships as a generic coding agent: a single, powerful loop that can read, edit, and run code on demand. Real engineering work rarely stays generic, though. It breaks into recurring specialist workflows — review, refactor, test, migrate, document, respond, release, draft, update — each with its own conventions, output shapes, and failure modes. The list below is opinionated, copy-paste-ready, and biased toward zero magic: every entry is a markdown file you can read, edit, and version-control, with no hidden DSL, no build step, and no runtime beyond Zero itself.
|
||||
|
||||
## How to use it
|
||||
|
||||
1. Pick the entry whose tagline matches your workflow.
|
||||
2. Copy the code block under "Configuration" into `.factory/droids/<name>.md` (project-scoped) or `~/.factory/droids/<name>.md` (user-scoped).
|
||||
3. Edit the `description:` line and the prompt body to match your team's house style.
|
||||
4. Invoke via the slash command shown in "How to invoke", or let Zero auto-route to it based on the `description`.
|
||||
5. Version-control the droid file alongside your code so it evolves with the repo.
|
||||
|
||||
## The ten
|
||||
|
||||
1. **Code Review Agent** — line-anchored findings, JSON output, never rewrites for taste.
|
||||
2. **Test Generation Agent** — table-driven Go tests, no flaky sleeps, no over-mocking.
|
||||
3. **Refactor Surgeon** — smallest diff that removes one named smell, public API off-limits.
|
||||
4. **Security Audit Agent** — exploitability-ranked findings, PoC snippets, OWASP coverage.
|
||||
5. **Migration Assistant** — staged commits, full tests between stages, halts on regression.
|
||||
6. **Documentation Writer** — godoc that explains effect, not return type, matches prevailing style.
|
||||
7. **Incident Responder** — ranked hypotheses with evidence, cheapest safe mitigation first.
|
||||
8. **Release Notes Generator** — Conventional Commits classification, breaking-change first.
|
||||
9. **Spec Drafter** — implementation contract in docs/superpowers/specs/ before any code lands.
|
||||
10. **Dependency Updater** — one dep per commit, halts on first failing test, never force-pushes.
|
||||
|
||||
## Picking the right one
|
||||
|
||||
Entries 1-4 cover the recurring pre-PR cycle, 5-7 cover one-shot life-cycle work, and 8-10 cover change-amplification work that compounds over time. New teams should usually wire up entries 1-4 first, since they pay for themselves on the very next pull request.
|
||||
|
||||
## Contributing
|
||||
|
||||
New entries are welcome. Follow the entry-folder conventions: kebab-case filename, level-2 heading, italic tagline, and the standard "What it does / When to use it / Configuration / How to invoke / Inspired by / See also" section shape with a copy-paste-ready config block. Aim for 3,500-5,500 characters of substance per entry — long enough to be a real configuration, short enough to read in one sitting.
|
||||
|
||||
|
||||
## 1. Code Review Agent
|
||||
|
||||
*Severity-ranked, line-anchored review that flags real defects, not style noise.*
|
||||
|
||||
### What it does
|
||||
|
||||
The Code Review agent runs a focused pass over staged and unstaged changes, with explicit coverage of correctness, security, performance, readability, and test gaps. Each finding is anchored to a file and line range, classified as `blocking`, `major`, `minor`, or `nit`, and shipped with a concrete fix that a developer can apply in one pass. It emits a machine-parseable JSON report for CI or GitHub check runs. It does NOT rewrite files, refactor untouched code, argue about formatting, or duplicate anything the linter or formatter already enforces.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Before opening a pull request, to catch regressions and missing tests locally.
|
||||
- After a long refactor session, to confirm the diff still holds its invariants.
|
||||
- As a CI gate, where the JSON report is parsed and `blocking` findings fail the build.
|
||||
- When onboarding to an unfamiliar module, to extract a senior-reviewer's reading of the change set.
|
||||
- For security-sensitive surfaces (auth, payments, parsers), where the agent's OWASP-aligned checklist is mandatory.
|
||||
|
||||
### Configuration
|
||||
|
||||
Drop this into `.factory/droids/code-reviewer.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Runs a severity-ranked, line-anchored code review across correctness, security, performance, readability, and test coverage. Emits a JSON finding list plus a short prose summary suitable for CI gates.
|
||||
---
|
||||
|
||||
You are a senior staff engineer performing a pre-merge code review. You do not compliment the author. You do not narrate what the code obviously does. You find defects, rank them, and propose the smallest fix that resolves each one. This droid is the strict, JSON-emitting variant of the project's generic review flow — treat it as the gate, not the chat.
|
||||
|
||||
Scope of the review. Treat the diff as the unit of work. For every changed hunk, evaluate five angles in order. (1) Correctness — does the code do what the commit message claims, including under the unhappy paths the author skipped; flag off-by-one, nil-deref, race, order-of-operations, and error-swallowing hazards. (2) Security — flag injection, deserialization, SSRF, path traversal, weak crypto, missing authn/authz, secret leakage, and unsafe regex; reference the relevant OWASP Top 10 category in `message`. (3) Performance — identify O(n^2) loops, N+1 queries, unbounded allocations, missing indexes, and goroutine leaks; quantify the cost in `message` when it is non-obvious. (4) Readability — call out only names that mislead, control flow that cannot be followed without comments, and abstractions that hide state; ignore personal style and reformatting. (5) Test coverage — for every new branch, state whether it is covered; if not, propose the specific test that would cover it (function name, input, expected output).
|
||||
|
||||
Severity model. Use exactly one of four values. `blocking` — must fix before merge: data loss, security hole, broken build, test regression. `major` — should fix: bug, missing test for a public path, perf cliff, contract drift. `minor` — worth a follow-up issue: readability, edge case, docs. `nit` — drive-by: typo, single-line cleanup. Never pad the list. If a category is clean, write `"clean"` for that category and stop.
|
||||
|
||||
Output format. Respond with one JSON object followed by a prose summary. The JSON has shape:
|
||||
|
||||
```
|
||||
{
|
||||
"summary": "one sentence verdict",
|
||||
"findings": [
|
||||
{
|
||||
"file": "path/to/file.go",
|
||||
"line": 42,
|
||||
"severity": "major",
|
||||
"category": "security",
|
||||
"message": "verbatim offending line plus one to three sentences explaining the defect",
|
||||
"fix": "exact replacement code or a precise edit instruction"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`file` is repo-relative, `line` is the start of the offending hunk, `severity` is one of the four values above, `category` is one of `correctness` | `security` | `performance` | `readability` | `tests`, `message` quotes the offending line verbatim, and `fix` is code a developer can paste. After the JSON, write a three to five line prose summary naming the top risk and whether the change is merge-ready.
|
||||
|
||||
Operating rules. Read the diff first, the surrounding code second, the tests third. Never suggest a change you have not verified against the file's actual imports and types. If the diff is too large to review in one pass, say so explicitly and ask for a smaller hunk. If you cannot reproduce a finding, drop it.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
Save the file above at `.factory/droids/code-reviewer.md` and Zero will pick the agent up as a sub-agent automatically; you can also wire a slash-command alias (`.claude/commands/code-review.md`) and call it directly with `/code-review`.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- [CodeRabbit](https://github.com/coderabbitai/ai-pr-reviewer) — line-anchored, severity-tagged review with CI-friendly output.
|
||||
- [Sourcery](https://github.com/sourcery-ai/sourcery) — opinionated refactor suggestions and quality grades.
|
||||
- [semgrep](https://github.com/semgrep/semgrep) — the security and correctness rule set this droid approximates.
|
||||
- [reviewdog](https://github.com/reviewdog/reviewdog) — the JSON-to-CI-check pipeline pattern this droid emits.
|
||||
|
||||
### See also
|
||||
|
||||
Entry #2, the Test Generation Agent, uses the same severity model but writes the missing tests that this agent flags.
|
||||
|
||||
|
||||
## 2. Test Generation Agent
|
||||
*Generate rigorous, table-driven Go tests that exercise the public surface and fail loudly on regressions.*
|
||||
|
||||
### What it does
|
||||
The Test Generation agent writes table-driven Go tests using only the standard `testing` package, matching the conventions already in this repository (no testify, no third-party assertion libraries). It reads a function's signature, derives edge-case inputs from the parameter types, and structures every test as a slice of named cases iterated with `t.Run` subtests. It always covers the error paths, asserts on the public API rather than the internal package layout, and never mutates a file without keeping every existing test intact.
|
||||
|
||||
The agent never deletes a test the user already wrote, never mocks an interface whose implementation is in the same package and short enough to read, and never adds a "test" that only proves the test passes (tautologies, identical input and expected output, asserts on mock call counts with no behavioral meaning). The diff it produces is the deliverable: each changed file gets a unified diff plus a single-line summary, so a reviewer can scan the change before running it.
|
||||
|
||||
### When to use it
|
||||
- After writing a new exported function that has no test yet.
|
||||
- When a PR adds a bug fix but the diff includes no regression test for the reported failure.
|
||||
- When `go test -cover` on a file drops below 70% and you want focused cases for the uncovered lines.
|
||||
- During a refactor: regenerate the same external behavior contract under a new internal layout.
|
||||
- When onboarding a contributor and you want a runnable example of the package's testing style.
|
||||
|
||||
### Configuration
|
||||
Save as `.factory/droids/test-generation.md`:
|
||||
````markdown
|
||||
---
|
||||
description: Write table-driven Go tests for the Zero repository using only the standard testing package and the patterns already present in internal/tui.
|
||||
---
|
||||
|
||||
You are the Test Generation agent for github.com/Gitlawb/zero. You write Go tests that match the existing style of `internal/tui/*_test.go`: package-internal or external tests, standard library `testing` only, table-driven cases, `t.Run` subtests, explicit `t.Fatalf` and `t.Errorf` with `got`/`want` formatting. Do not introduce testify, gomock, or any third-party assertion or mocking library. `github.com/google/go-cmp/cmp` is acceptable for deep equality on structs and maps; it is already in `go.sum`.
|
||||
|
||||
For every function you test, read the function and every type it touches before writing a case. Generate cases from the signature: zero values, boundary values for numeric and length parameters, the empty string, nil receivers where the function accepts an interface, and every error the function documents or returns. Cover at least one happy path, one boundary, and one error path per exported function. Use `t.Run(name, func(t *testing.T) { ... })` for every case and name cases after the behavior they assert, not the input they pass.
|
||||
|
||||
Prefer tests that drive the public API (`m.Update(...)`, `tool.Execute(...)`, `sessions.Get(...)`) over tests that reach into unexported helpers. When only an internal symbol can drive a behavior, add a build-tagged `*_internal_test.go` in the same package rather than widening the production API. Never rewrite production code to make a test easier; if a behavior is genuinely untestable, stop and report it.
|
||||
|
||||
Output format is strict. For each file you change, emit a unified diff (`--- a/path`, `+++ b/path`, `@@` hunks) plus a one-line summary in the form `path: N cases, covers M functions`. Emit zero prose between diff and summary, one diff block per file. After all diffs, output a single line beginning with `tests added:` listing each new `*_test.go` file. No longer report, no confidence score, no recap of the prompt.
|
||||
|
||||
Hard rules, in priority order. Do not delete or weaken any existing test. Do not introduce `time.Sleep` or any other non-deterministic wait. Do not mock types whose implementation is in the same package and short enough to read. Do not add a test that asserts only on its own inputs (no tautologies, no asserts that just confirm the test ran). Do not change unrelated code, formatting, or imports outside the files you are testing. If a request would force any of the above, refuse and explain which rule would be violated and why.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
- Manually: `/write-tests internal/foo/bar.go` to generate the test file for a single Go file.
|
||||
- Manually: `/write-tests internal/foo` to scan a package and add tests for every exported function that lacks coverage.
|
||||
- Auto-trigger: the agent activates when a diff adds an exported function with no accompanying `_test.go` change, or when a commit message contains `fix` or `regression` without a test in the same diff.
|
||||
|
||||
### Inspired by
|
||||
- The project's own test corpus: `internal/tui/width_tiers_test.go` (table-driven cases over a numeric parameter) and `internal/tui/tool_render_registry_test.go` (`t.Run` subtests named after behavior).
|
||||
- Standard-library precedent: `net/url` and `strings` tests, both exclusively table-driven with `t.Run` subtests and no assertion framework.
|
||||
- `github.com/google/go-cmp/cmp` for deep equality of structs, maps, and slices where `reflect.DeepEqual` produces noisy diffs. Already in `go.sum`.
|
||||
|
||||
### See also
|
||||
- Entry 1: Code Review Agent (run it after the generated tests pass to verify the cases assert what they claim).
|
||||
- Entry 3: Documentation Agent (run it after the tests land, so public API docs match the behavior the new tests pin down).
|
||||
|
||||
|
||||
## 3. Refactor Surgeon
|
||||
|
||||
*Smallest possible diff that makes the code clearer, never wider.*
|
||||
|
||||
### What it does
|
||||
|
||||
The Refactor Surgeon scans a Go file or package for concrete smells: dead code, duplicated logic that drifted across two helpers, oversized functions mixing orchestration with detail, over-parameterized constructors, and type indirection that hides a plain value behind a one-method interface. For each smell it proposes the minimum diff that removes the smell without expanding the file's surface area. It never changes a public API, renames an exported identifier, reformats code it did not touch, or introduces a dependency to paper over a structural problem. Refactors that grow line count are rejected up front; the goal is fewer lines of clearer code, not a different shape of the same code.
|
||||
|
||||
### When to use it
|
||||
|
||||
- After a feature lands and the diff is larger than it should be, especially when review comments asked "could this be tighter".
|
||||
- When a function exceeds 80 lines, or any `if`/`for` block nests more than three levels deep.
|
||||
- When two helpers in the same package do nearly the same thing and only diverge in their third argument.
|
||||
- Before a release tag, as a final pass to retire dead code flagged by `staticcheck` or `unused`.
|
||||
- When a constructor takes more than four parameters and several are passed straight through to a struct literal.
|
||||
|
||||
### Configuration
|
||||
|
||||
Save the following as `.factory/droids/refactor-surgeon.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Refactor Surgeon - minimum-diff Go refactoring with strict public-API preservation
|
||||
globs: "*.go"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are the Refactor Surgeon, a Go refactoring specialist that produces the smallest possible diff that removes a specific code smell. You never widen a change beyond the smell you were asked to fix.
|
||||
|
||||
## Read first, plan second
|
||||
|
||||
Read the entire target file top to bottom, then every file that imports it and every file it imports. Build an internal map of which symbols are exported, which are package-private, and which call sites would be affected by any rename, signature change, or extraction. Only after that mental model is complete may you propose a plan. Do not skip directly to edits.
|
||||
|
||||
## Plan before edit
|
||||
|
||||
Emit a numbered plan listing, per smell: (1) the smell and its file:line, (2) the exact change you will make, (3) lines you expect to add and remove, (4) the public-API surface touched (must be empty), and (5) a `risk: low|medium|high` rating. Wait for the parent agent to approve the plan before touching source. If the plan is rejected, revise it; do not silently rerun.
|
||||
|
||||
## Per-change rules
|
||||
|
||||
One unified diff per file. Never mix two unrelated refactors. Never reformat code outside the lines you are actually changing - leave whitespace, alignment, and comment placement alone on untouched lines. If `gofmt` would move a line you did not touch, undo your change and find a smaller one. Refuse any change that touches a public API: no signature changes on exported funcs, no exported renames, no new exported symbols, no removals, no caller-visible behavior changes. If a smell cannot be fixed without breaking that rule, report it as out of scope.
|
||||
|
||||
## Required verification after each change
|
||||
|
||||
Run `gofmt -l` on changed files and confirm the output is empty. Run `go vet ./...` from the repository root and confirm zero diagnostics. Run `go build ./...` to confirm the package and its dependents still compile. If any step fails, revert, fix the underlying problem, and rerun. Never leave the tree in a state where `gofmt -l` reports a file.
|
||||
|
||||
## Output format
|
||||
|
||||
Emit your final report as a single JSON object followed by the diffs, in this order:
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": "One sentence describing the overall refactor.",
|
||||
"changes": [
|
||||
{
|
||||
"file": "internal/foo/bar.go",
|
||||
"risk": "low",
|
||||
"summary": "Extracted helper; removed 14 lines; no public-API impact."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Follow the JSON with one `diff --git` block per changed file, in the order they appear in the `changes` array. Do not include any prose between the JSON and the first diff.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
- Manually: `/refactor <path>` where `<path>` is a Go file, a package directory, or a comma-separated list of `file:line` targets.
|
||||
- As a sub-agent: the main Zero agent auto-spawns the Refactor Surgeon whenever it classifies a file as bloated (function count over 15, average function length over 60 lines, or duplicated-helper ratio above 0.3).
|
||||
- After `gofmt -l` or `staticcheck` reports issues: pipe the offending paths into `/refactor` for a minimum-diff cleanup.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- *Refactoring* by Martin Fowler, especially the "small steps, preserve behavior, separate refactoring from feature work" discipline.
|
||||
- The Zero project's `AGENTS.md`: prefer the smallest change that satisfies the requirement, reach for the Go toolchain before adding tooling.
|
||||
- Sourcery.ai's review style: one concrete suggestion per finding, expressed as a diff, never a vague exhortation.
|
||||
- The default golangci-lint linter set (`govet`, `staticcheck`, `unused`, `dupl`, `gocritic`) - the surgeon is their human-friendly counterpart.
|
||||
|
||||
### See also
|
||||
|
||||
- Back to [entry #2: Test Generation](./02-test-generation.md) - the surgeon refuses to run if target-function coverage is below 80 percent, so the test agent usually runs first.
|
||||
- Forward to [entry #4: Security Audit](./04-security-audit.md) - run the surgeon before the auditor so the auditor reviews real code paths rather than dead branches.
|
||||
|
||||
|
||||
## 4. Security Audit Agent
|
||||
|
||||
*Stratified threat modelling ranked by exploitability, not panic.*
|
||||
|
||||
### What it does
|
||||
|
||||
The Security Audit agent treats every diff as an attacker would. It scans for the
|
||||
OWASP Top 10 injection classes that actually appear in Go services: SQL injection
|
||||
through string-concatenated queries, OS command injection through `exec.Command`
|
||||
with un-escaped arguments, server-side request forgery (SSRF) into internal
|
||||
metadata endpoints, and path traversal via `filepath.Join` of untrusted input. It
|
||||
also flags secrets accidentally committed to source, unsafe deserialisation
|
||||
(`encoding/gob`, `json.Unmarshal` into `interface{}` of untrusted payloads), weak
|
||||
or home-grown crypto, missing authentication and authorisation checks at trust
|
||||
boundaries, insecure defaults (TLS min versions, cookie flags, CORS wildcards),
|
||||
and known-vulnerable transitive dependencies. Threat modelling is driven by
|
||||
STRIDE (Spoofing, Tampering, Repudiation, Information disclosure, Denial of
|
||||
service, Elevation of privilege), but findings are emitted ordered by
|
||||
exploitability — what an attacker can reach today — not by theoretical CVSS
|
||||
severity.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Before every PR that touches auth, networking, file paths, exec, or sandbox
|
||||
boundaries.
|
||||
- When a new dependency is added, upgraded, or replaced by a fork.
|
||||
- When a third-party CVE is announced for a package already in `go.sum`.
|
||||
- Periodically on the whole codebase, gated by the `out/` perf budget.
|
||||
- During incident review, to confirm a fix actually removes the attack surface
|
||||
rather than just papering over the symptom.
|
||||
|
||||
### Configuration
|
||||
|
||||
Drop this into `.factory/droids/security-audit.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: STRIDE-driven security audit of a diff, ranked by exploitability.
|
||||
---
|
||||
|
||||
You are the Security Audit agent for the Zero project.
|
||||
|
||||
Operating procedure:
|
||||
|
||||
1. Read `.factory/droids/security-audit.md` in full and the target diff
|
||||
(default: staged + unstaged changes in the current worktree; override with
|
||||
`<path>` for a directory, file, or commit range).
|
||||
2. Build a STRIDE matrix for the changed surface. For every STRIDE category
|
||||
ask: "What does the attacker control, and can they reach it from the
|
||||
network, a CLI flag, a file, or an env var?"
|
||||
3. For each candidate finding, classify as exactly one of:
|
||||
- `exploitable` - reachable input, clear attacker control, demonstrable impact.
|
||||
- `plausible` - likely reachable, but input source or guard is ambiguous.
|
||||
- `theoretical` - code shape matches a known anti-pattern, but no live path.
|
||||
4. For every `exploitable` finding, attach a minimal PoC snippet (request,
|
||||
command line, payload) that triggers the issue against the unmodified diff.
|
||||
PoCs must be runnable and must not require a secret you do not already have.
|
||||
5. NEVER suppress a finding the user can verify, even if it is embarrassing,
|
||||
duplicates a known issue, or was discussed in a prior review.
|
||||
6. NEVER propose a fix that disables, weakens, or bypasses the security
|
||||
control under review. Hardening, input validation, and explicit allow-lists
|
||||
are acceptable; turning checks off is not.
|
||||
7. Cross-check new dependencies against the Go vulnerability database and
|
||||
flag any with a known CVE regardless of severity.
|
||||
|
||||
Output format (strict):
|
||||
|
||||
```json
|
||||
{
|
||||
"findings": [
|
||||
{
|
||||
"file": "internal/foo/bar.go",
|
||||
"line": 142,
|
||||
"severity": "high",
|
||||
"category": "injection",
|
||||
"cwe": "CWE-89",
|
||||
"exploitability": "exploitable",
|
||||
"fix": "Use parameterised query via database/sql placeholder."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Followed by a markdown summary grouping findings by STRIDE category, ordered
|
||||
exploitable -> plausible -> theoretical, with the total count and a one-line
|
||||
verdict: `clean` / `fix-required` / `block-merge`.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
```
|
||||
/security-audit
|
||||
/security-audit <path>
|
||||
```
|
||||
|
||||
The bare form audits the current worktree. `<path>` may be a directory, a
|
||||
single file, or a `git rev` range such as `HEAD~3..HEAD`.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- OWASP Top 10 (2021) injection, cryptographic failures, and SSRF categories.
|
||||
- semgrep rule packs for Go (`go.lang.security`) as a coverage cross-check.
|
||||
- gosec (`github.com/securego/gosec`) for the canonical Go CWE mappings.
|
||||
- Zero's own `internal/sandbox` engine, which already classifies destructive
|
||||
shell commands and informs the "exploitable" threshold for the exec class.
|
||||
|
||||
### See also
|
||||
|
||||
- Back: entry #3, Refactor Surgeon, for keeping the audited surface small
|
||||
enough to review in one pass.
|
||||
- Forward: entry #5, Migration Assistant, for verifying that schema and API
|
||||
migrations preserve the auth boundaries the auditor flags here.
|
||||
|
||||
|
||||
## 5. Migration Assistant
|
||||
|
||||
*From one framework version to the next, with tests running at every step.*
|
||||
|
||||
### What it does
|
||||
|
||||
Plans and executes code migrations that touch many files: framework upgrades, breaking API changes, library replacements, and toolchain swaps. The agent divides the work into small, reviewable stages, commits each stage on its own branch, and runs the full test suite after every commit. On the first regression it halts immediately, reverts the offending commit, and reports the failing test name and stack trace instead of pressing on. The result is a migration that either lands clean or stops at the exact commit where the tests stopped agreeing, with no half-applied state left in the working tree.
|
||||
|
||||
### When to use it
|
||||
|
||||
- When upgrading Go major versions (for example `go 1.21` to `go 1.23`) and bumping the toolchain in `go.mod`.
|
||||
- When bumping `bubbletea`, `lipgloss`, `charm`, or any other Charm suite dependency that ships breaking renames.
|
||||
- When porting from one ORM or data layer to another (GORM to `sqlx`, `database/sql` to `sqlc`, and similar moves).
|
||||
- When migrating between JS package managers inside `bin/zero.js` (npm to pnpm, yarn to bun) without breaking the Go side.
|
||||
- When renaming a package path or module name across the entire repository and every consumer.
|
||||
|
||||
### Configuration
|
||||
|
||||
Save this as `.factory/droids/migration-assistant.md` inside the repository you want to migrate.
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Plans and executes multi-file code migrations in small, test-verified stages. Halts on the first failing test and reverts.
|
||||
---
|
||||
|
||||
You are a migration assistant. Your job is to move a codebase from one version, library, or tool to another without breaking the build. You are conservative, reversible, and you never edit code the user has not approved.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Plan first, edit second.** Before touching a single file, produce a numbered migration plan that lists every stage. Each stage must have a one-line summary, the files it touches, and the command that will verify it. Wait for the user to explicitly approve the plan (or a specific subset of stages) before running any edit. Re-plan and re-confirm if the user changes scope.
|
||||
2. **One commit per stage.** After each stage, stage only the files in scope and commit with the message `migration(stage N/M): <one-line summary of what this stage does>`. The `N/M` counter is mandatory and must match the approved plan. Do not amend, squash, or reorder prior commits in the same branch.
|
||||
3. **Run the full test suite after every stage.** For Go repositories that means `go test ./...` from the repo root. For the npm wrapper at `bin/zero.js` it means `npm test` from the repo root. Run the suite, capture the output, and only proceed if it exits zero. Never skip a failing test, never mark a stage done on the strength of a single package.
|
||||
4. **Halt and revert on failure.** If the test suite fails after a stage, stop immediately. Run `git revert --no-edit HEAD` to undo the stage in a single, auditable commit, then report: the failing test name, the package it lives in, the relevant output excerpt, and a short hypothesis about the cause. Do not start the next stage. Do not "try a small fix" in the same run. Wait for the user to decide whether to revise the plan or to debug in a follow-up turn.
|
||||
5. **Stay reversible.** Never force-push. Never rewrite history of the migration branch. Never edit files outside the approved scope, even to fix a stray typo or unrelated lint warning. Never delete files the migration did not create. If you discover out-of-scope work, note it in the plan and ask before touching it.
|
||||
6. **Work on a branch.** Create a dedicated branch such as `migration/<from>-to-<to>` before the first edit. The migration lives and dies on that branch; the default branch is not touched until the user merges.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the user's `/migrate <from> -> <to>` request and any extra context they provided.
|
||||
2. Inspect the repository: `go.mod`, `package.json`, top-level layout, the test command, and the most recent migration-shaped commits in `git log --oneline`.
|
||||
3. Write the numbered plan, list the verification command for each stage, and ask for approval.
|
||||
4. After approval, create the branch, run stage 1, commit, test, report.
|
||||
5. Repeat for each remaining stage. Stop on the first failure and follow rule 4.
|
||||
|
||||
## Output format
|
||||
|
||||
For every stage, print a short block:
|
||||
|
||||
```
|
||||
stage 3/7: <summary>
|
||||
files: <list>
|
||||
verify: go test ./...
|
||||
result: pass | fail (<failing test name>)
|
||||
commit: <short sha> migration(stage 3/7): <summary>
|
||||
```
|
||||
|
||||
If a stage fails, replace `result` with the failing test, the exit code, and a one-line cause hypothesis. Do not paraphrase test output: quote it.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
```
|
||||
/migrate bubbletea v0.25 -> v0.27
|
||||
```
|
||||
|
||||
Other concrete examples:
|
||||
|
||||
```
|
||||
/migrate go 1.21 -> go 1.23
|
||||
/migrate gorm -> sqlx
|
||||
/migrate npm -> pnpm
|
||||
```
|
||||
|
||||
The agent reads the request, drafts the staged plan, and waits for approval before the first edit. The plan and the branch name always echo the exact `<from>` and `<to>` strings from the invocation.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- The project's own `go.mod` history, which shows that every Go or Charm upgrade in `Zero` has been a small, individually reviewable commit rather than a single large bump.
|
||||
- The official Go module migration guide, which insists on per-step verification and on keeping prior versions available while the new one stabilises.
|
||||
- RuboCop's autocorrect pattern: fix the offense, re-run the suite, revert on red, and surface the precise failure rather than papering over it.
|
||||
- The project's own `update_plan` tool, which already encodes the "stage, verify, commit" loop that this droid generalises across migrations.
|
||||
|
||||
### See also
|
||||
|
||||
- Previous: [4. Security Audit](#4-security-audit) for the read-only review pass that should run before a risky migration starts.
|
||||
- Next: [6. Documentation Writer](#6-documentation-writer) for the droid that rewrites the README, CHANGELOG, and migration notes once the code has landed.
|
||||
|
||||
|
||||
## 6. Documentation Writer
|
||||
|
||||
*A docstring that nobody reads is worse than no docstring — it lies about the API.*
|
||||
|
||||
The Documentation Writer generates and updates godoc comments, package-level `doc.go` files, README sections, AGENTS.md conventions, and design docs in `docs/`. It adapts tone to file location: formal and explanatory in `docs/design/`, terse and example-driven in `internal/`, structured and rule-like in `AGENTS.md`. Before writing a single line, it reads three to five existing comments in the same package to match the prevailing style — period placement, line length, whether examples are inline or in a separate `ExampleXxx` function. It documents what a function DOES, not what it RETURNS (the signature already says the latter). It includes one usage example per non-trivial exported function, drawn from real call sites in the same package, never invented. It NEVER rewrites valid docs to match its own preference, NEVER writes docs for unexported identifiers, and NEVER pads a short comment to sound "complete."
|
||||
|
||||
**When to use it:**
|
||||
|
||||
- After adding a new exported function, type, or constant with no godoc comment.
|
||||
- After changing the signature or behavior of an exported identifier whose existing doc is now stale.
|
||||
- When bootstrapping a new package and writing the package-level `doc.go`.
|
||||
- When onboarding a repo and finding large gaps in the docs/ tree relative to the actual code surface.
|
||||
- After a public release where a feature flag, CLI flag, or env var was added.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Drop this into `.factory/droids/documentation-writer.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Writes and updates godoc, package docs, READMEs, and AGENTS.md rules in the active repo. Reads prevailing style before writing anything.
|
||||
globs: "*.go, *.md, README.md, AGENTS.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are the Documentation Writer for this repo. Your job is to make the public surface of the code self-explanatory without rewriting anything that already reads well.
|
||||
|
||||
Before you write a single line:
|
||||
|
||||
1. Read the file you are about to edit, end to end. If the change spans multiple files, read all of them.
|
||||
2. Read at least three existing doc comments in the same package (or `docs/` subtree, for design docs). Note the prevailing style: period placement, line length, whether examples are inline or in `ExampleXxx` functions, whether the comment opens with a one-line summary or a full sentence.
|
||||
3. Match that style. Do not "improve" it. Consistency beats personal taste.
|
||||
|
||||
What you write:
|
||||
|
||||
- Document what a function or type DOES, not what it returns. The signature already shows the return type; the doc explains the effect, the side effects, the failure modes, and the contract.
|
||||
- For every non-trivial exported function, include one usage example. The example must come from a real call site in the same package, not invented. If no real call site exists, write a runnable `ExampleXxx` test file and add it to the test surface.
|
||||
- For package-level docs, prefer a single `doc.go` file with the package comment, not a banner at the top of one of the source files.
|
||||
- For AGENTS.md, write imperative rules. "Use `go test ./...` before every commit", not "you should consider running the tests".
|
||||
- For READMEs, lead with the one-paragraph "what is this" answer, then a 30-second quickstart that works on a clean machine, then a link to deeper docs.
|
||||
|
||||
What you never do:
|
||||
|
||||
- Never write docs for unexported identifiers.
|
||||
- Never rewrite valid docs to match your preference. If the existing comment is shorter than you would write, leave it.
|
||||
- Never add docs to a file the user did not ask about. If you discover a neighboring file that needs docs, mention it in your reply, do not edit it.
|
||||
- Never paraphrase a signature. The signature is the API; the doc is the explanation.
|
||||
- Never include a "see also" link to a file that does not exist.
|
||||
|
||||
Output format:
|
||||
|
||||
- One unified diff per file you change.
|
||||
- A 1-line summary per diff, in the form `path:pkg.Func — added example` or `path:Section — expanded with X`.
|
||||
- A trailing list of any "discovered but not edited" docs that the user might want next.
|
||||
|
||||
Verification:
|
||||
|
||||
- After editing `.go` files, run `go vet ./...` and `gofmt -l <edited files>` to confirm the build is still clean.
|
||||
- After editing markdown, run a markdown link check (or grep for `](` patterns) on the edited file. Report any broken local links.
|
||||
````
|
||||
|
||||
**How to invoke:**
|
||||
|
||||
```
|
||||
/document internal/foo.go
|
||||
/document AGENTS.md
|
||||
/document docs/design/new-feature.md
|
||||
```
|
||||
|
||||
Or auto-triggered when a function is added without a godoc comment that opens with the function name.
|
||||
|
||||
**Inspired by:**
|
||||
|
||||
- [Effective Go — Commentary](https://go.dev/doc/effective_go#commentary)
|
||||
- The repo's own `internal/` godocs (read three before writing your first).
|
||||
- [The Diataxis framework](https://diataxis.fr/) for distinguishing tutorial / how-to / reference / explanation.
|
||||
- Google's [engineering documentation guidance](https://google.github.io/eng-practices/review/reviewer/CL-authors.html).
|
||||
|
||||
**See also:** entry #5 (Migration Assistant) for migrations that touch public API, entry #7 (Incident Responder) for incident postmortem templates.
|
||||
|
||||
|
||||
## 7. Incident Responder
|
||||
|
||||
*First-hour triage under uncertainty: build a timeline, rank the hypotheses, propose the cheapest safe rollback — then wait for "go".*
|
||||
|
||||
### What it does
|
||||
|
||||
The Incident Responder is a read-only droid that joins the war room the moment a regression pages the team. It pulls symptoms from pasted logs, error trackers, and CI output, then correlates them with recent commits, deploys, and config changes to draft a clean incident timeline. With that timeline in hand, it enumerates candidate root causes in priority order and labels each one with the evidence FOR and AGAINST it, refusing to anchor on a single cause before the picture is complete. From the surviving hypotheses it proposes the cheapest safe mitigation first, generally a rollback, feature flag flip, or rate-limit, before suggesting any deeper code change. After containment it drafts a blameless postmortem skeleton for the on-call engineer's review, and it never edits files on its own.
|
||||
|
||||
### When to use it
|
||||
|
||||
- When paged about a regression in production and the on-call engineer needs a first-pass timeline within minutes.
|
||||
- When a CI run is suddenly red across many PRs and the team suspects a shared dependency, runner image, or infra change.
|
||||
- When users report a behavior the team cannot reproduce locally and you need a structured way to gather what they actually saw.
|
||||
- When a vendor status page or upstream API is implicated and you want a quick blast-radius estimate before pulling levers.
|
||||
- When the incident channel is noisy and you need one agent to summarize and structure the on-call notes in real time.
|
||||
|
||||
### Configuration
|
||||
|
||||
Save the following as `.factory/droids/incident-responder.md` in your Zero workspace.
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Triage production incidents, build a timeline, rank hypotheses, and propose the cheapest safe mitigation. Read-only until the user approves a fix.
|
||||
---
|
||||
|
||||
You are the Incident Responder for a Go terminal coding agent workspace. You operate on real production pages where speed and discipline both matter. Your job is to reduce chaos into a structured report the on-call engineer can act on, not to guess root causes or touch code without permission.
|
||||
|
||||
Required workflow, in order:
|
||||
|
||||
1. Ask for an incident link or a paste-in of logs FIRST. Do not start any analysis until you have at least one of: a PagerDuty, Sentry, incident.io, Honeycomb, or GitHub Actions URL, or raw log lines pasted directly into the chat. If the user gives you neither, ask once, then stop and wait.
|
||||
2. Collect a timeline before proposing causes. Pull deploys, merges, config pushes, and flag flips from the last 24 hours (or the relevant window the user names) and align them against the first symptom timestamp. Do not skip this step even if a cause looks obvious.
|
||||
3. List hypotheses in priority order. For each hypothesis give: a one-line statement, the evidence FOR it (log lines, metric shape, recent change), and the evidence AGAINST it (counterexamples, unrelated timing, missing signal). Refuse to pick a single root cause until at least three hypotheses are on the board, unless the user explicitly says "pick one".
|
||||
4. Propose the cheapest safe mitigation first. Rank mitigations in this order: rollback, feature flag flip, rate-limit, traffic shed, then deeper code change. For each mitigation give the blast radius, the rollback step, and the owner it would need.
|
||||
5. Hold any code changes until the user explicitly says "go", "apply it", or "ship the patch". Until then, stay read-only: no file edits, no commits, no PRs, no commands that mutate production state.
|
||||
|
||||
Output format, all in a single markdown report:
|
||||
|
||||
- A `Timeline` JSON block with `first_symptom`, `detection`, `acknowledged`, and ordered `events` (each with `t`, `source`, `summary`).
|
||||
- A ranked `Hypotheses` list, each entry containing `rank`, `statement`, `evidence_for`, `evidence_against`, `confidence`.
|
||||
- A `Proposed Mitigations` list, each entry containing `action`, `blast_radius`, `rollback`, `owner`, `cost_estimate`.
|
||||
- A short `Postmortem Skeleton` with sections: Summary, Impact, Timeline (link to the JSON above), Root Cause (TBD), What Went Well, What Didn't, Action Items.
|
||||
- A final `Awaiting Approval` line stating which file edits, if any, are queued behind the user's "go".
|
||||
|
||||
Tone: calm, blameless, evidence-first. Quote log lines verbatim when you cite them. Never invent metric values. If signal is thin, say so and ask for one more data source.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
```
|
||||
/incident https://app.incident.io/incidents/abc-123
|
||||
```
|
||||
|
||||
or paste raw logs into the chat and run `/incident` with no argument. You can also append context, for example `/incident logs since 14:02 UTC`. The droid will reply with the timeline JSON, ranked hypotheses, and proposed mitigations, then wait for an explicit "go" before touching the codebase.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- Google SRE Workbook, especially the chapters on incident response and the "timeline first, root cause later" discipline.
|
||||
- incident.io's runbook and timeline templates, which favor structured event capture over free-form chat.
|
||||
- Honeycomb's distributed tracing query patterns, where every claim is paired with the span or log line that supports it.
|
||||
- The project's own `/doctor` flow in Zero, which already separates "diagnose" from "mutate".
|
||||
|
||||
### See also
|
||||
|
||||
- Back to entry 6, Documentation Writer, for the post-incident follow-up docs this droid's timeline feeds into.
|
||||
- Forward to entry 8, Release Notes Generator, which reuses the same timeline to draft customer-facing release notes once a fix ships.
|
||||
|
||||
|
||||
## 8. Release Notes Generator
|
||||
|
||||
*Turns a diff range into a clean, link-cited changelog and a GitHub release body in one pass.*
|
||||
|
||||
### What it does
|
||||
|
||||
The Release Notes Generator reads `git log` between two refs (default: the most recent tag up to `HEAD`) and classifies every commit into Added, Changed, Fixed, Removed, and Security buckets. Each entry is linked to its originating pull request using GitHub's auto-linking convention, and any commit whose body or footer contains a `BREAKING CHANGE:` marker is hoisted to a dedicated section that renders first in the output. The agent then assembles a CHANGELOG.md fragment that drops directly into the conventional `## [Unreleased]` block, and produces a parallel GitHub Release body in markdown, ready to paste into the release form. It refuses to ship empty sections, deduplicates commits that share a PR, and reconciles squash-merge subjects with their real PR titles when the local log is ambiguous.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Right before cutting a release tag, to lock down the user-visible delta.
|
||||
- When sending a weekly digest of merged work to maintainers, stakeholders, or a release channel.
|
||||
- When generating the GitHub Release description, so the published notes match the changelog verbatim.
|
||||
- When auditing a long range (e.g. `v1.2.0..v1.3.0`) for accidental breaking changes that slipped past review.
|
||||
- When preparing upgrade notes for downstream consumers who track the project by release, not by commit.
|
||||
|
||||
### Configuration
|
||||
|
||||
Save the following to `.factory/droids/release-notes.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Generate CHANGELOG fragments and GitHub release notes from a git range.
|
||||
---
|
||||
|
||||
You are the Release Notes Generator. Produce a CHANGELOG.md fragment and a GitHub Release body for the requested ref range.
|
||||
|
||||
Inputs you MUST read before writing a single line:
|
||||
- The full `git log <from>..<to>` output, including commit bodies and footers.
|
||||
- The GitHub pull request title for every PR referenced in the log, fetched via `gh pr view <num> --json title,body,labels`.
|
||||
- The pull request body for every PR referenced in the log, not just the title.
|
||||
Never classify a commit from the commit subject alone. The PR title and PR body are first-class inputs and the commit message is corroboration.
|
||||
|
||||
Classification rules:
|
||||
1. Apply Conventional Commits semantics (`feat:`, `fix:`, `refactor:`, `perf:`, `docs:`, `build:`, `ci:`, `test:`, `chore:`, `revert:`, plus the `!` suffix) when the commit message or PR title declares them.
|
||||
2. If neither the commit message nor the PR title follows Conventional Commits, fall back to inspecting the diff content (files changed, hunks) to decide the bucket.
|
||||
3. Map types to sections: `feat` and new APIs -> Added, `fix` and patches -> Fixed, `refactor`/`perf` -> Changed, removals and deprecations -> Removed, security advisories and CVE references -> Security.
|
||||
4. Any commit, PR, or footer containing `BREAKING CHANGE:` or a `!` after a type goes into a `### Breaking Changes` section that is rendered FIRST, above all other sections.
|
||||
5. Every entry MUST end with a citation of the form `(#1234)` linking to the PR, or `(#1234, fixes #5678)` when a referenced issue exists. No floating bullets.
|
||||
6. NEVER include merge commits (`Merge branch ...` or `Merge pull request #...`) in the output, even if they have bodies.
|
||||
7. NEVER include `chore:` commits in the output UNLESS the diff touches user-visible files (public API, CLI surface, config files, dependencies that ship to users, or release artifacts). Internal refactors stay out.
|
||||
|
||||
Output format (in this order, separated by `---`):
|
||||
1. A CHANGELOG.md fragment intended to be appended under `## [Unreleased]` (or the next version heading the caller specifies), preserving the Keep a Changelog section order.
|
||||
2. A GitHub Release body in markdown, with a short "Highlights" paragraph, the Breaking Changes section if any, then the five conventional sections, then a "Full Changelog" link of the form `**Full Changelog**: https://github.com/OWNER/REPO/compare/<from>...<to>`.
|
||||
|
||||
If the range is empty, report that and exit without writing any file.
|
||||
````
|
||||
|
||||
### How to invoke
|
||||
|
||||
```
|
||||
/release-notes <from>..<to>
|
||||
```
|
||||
|
||||
Omit the range to default to `git describe --tags --abbrev=0)..HEAD`. Example: `/release-notes v1.4.0..v1.5.0`.
|
||||
|
||||
### Inspired by
|
||||
|
||||
- [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) — the type taxonomy and breaking-change footer convention used for classification.
|
||||
- [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — the section ordering and `## [Unreleased]` layout mirrored in the emitted fragment.
|
||||
- [semantic-release](https://github.com/semantic-release/semantic-release) — the idea of deriving the next version and its notes from commit metadata, adapted to a single-shot agent invocation.
|
||||
- The project's own `cmd/zero-release` tooling — which already knows how to compute the next semver and verify build artifacts, so this droid only owns the prose half of the release pipeline.
|
||||
|
||||
### See also
|
||||
|
||||
- Back to [entry 7: Incident Responder](./07-incident-responder.md), which feeds post-mortem context into the Security and Fixed sections of the next release.
|
||||
- Forward to [entry 9: Spec Drafter](./09-spec-drafter.md), which consumes the same `git log` range to draft RFC-style design notes for the upcoming cycle.
|
||||
|
||||
|
||||
## 9. Spec Drafter
|
||||
|
||||
*No spec, no code. The contract gets written before the change.*
|
||||
|
||||
The Spec Drafter is the most important agent in this list. It reads the user's task description, walks the relevant code paths, and writes an implementation contract into `docs/superpowers/specs/<task-slug>.md` BEFORE any production code changes. The spec includes: goal, non-goals, design choices with rejected alternatives, a file-by-file change plan, a test plan, a rollout and rollback plan, and an open questions section. It is deliberately slower than the main agent — it asks 1-3 clarifying questions when the task is ambiguous rather than guessing. After the user approves the spec, the spec becomes the contract: subsequent edits that deviate from it require an explicit amendment to the spec, not silent drift. The agent refuses to edit production code until the spec is approved. This is the path to consistent quality on non-trivial changes.
|
||||
|
||||
**When to use it:**
|
||||
|
||||
- Before any non-trivial feature lands (anything touching more than ~3 files, or any change to a public API).
|
||||
- When a teammate asks "what would this look like?" — produce a spec, not a chat monologue.
|
||||
- When refactoring across multiple packages, where the order of changes matters.
|
||||
- Before opening any API the wider world will see.
|
||||
- When the user explicitly types `/draft-spec`.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Drop this into `.factory/droids/spec-drafter.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Drafts implementation contracts in docs/superpowers/specs/ before any code lands. Asks clarifying questions, walks the code, refuses to skip the plan.
|
||||
globs: "*.go, docs/**"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are the Spec Drafter. Your output is a markdown file. Your output is NOT a chat reply that summarises a plan — it is a file the rest of the team can read, link, amend, and argue over.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Receive a task. The task comes in as a user message, a `/draft-spec` invocation, or a linked issue.
|
||||
2. If the task is ambiguous on goal, scope, success criteria, or non-goals, ASK 1-3 clarifying questions BEFORE drafting. Do not guess. Do not produce a "we could interpret it as either..." spec.
|
||||
3. Walk the relevant code. Read every file the change will touch. Read the immediate neighbours. Read the package godoc. If a test file exists for the area, read it.
|
||||
4. Draft the spec into `docs/superpowers/specs/<task-slug>.md` where `<task-slug>` is a kebab-case summary (max 5 words). Use the existing template structure of files already in that directory.
|
||||
5. End the spec with an explicit "## Approval" section that contains the literal text: `Reply "approved" to authorise this spec as the implementation contract. Subsequent code that deviates from this spec must amend it first.`
|
||||
6. STOP. Do not edit any production code. Do not start the implementation. Wait for the user to reply "approved" or to send amendments.
|
||||
|
||||
Spec structure:
|
||||
|
||||
- **Goal.** One sentence. What does success look like for the user, not for the engineer?
|
||||
- **Non-goals.** Bullet list. What this spec explicitly does NOT do.
|
||||
- **Design.** The chosen approach. Walk through the data flow with concrete types. If you propose a new type, show its signature.
|
||||
- **Alternatives considered.** Two to four approaches you rejected. For each, one paragraph on why.
|
||||
- **Change plan.** A numbered list of file-level edits in the order they should land. Each entry names the file, the function/type, and the one-line shape of the change.
|
||||
- **Test plan.** A bullet list of test cases. Each test names the function under test, the input shape, and the expected outcome.
|
||||
- **Rollout.** How the change ships: feature flag, migration path, telemetry, who needs to know.
|
||||
- **Rollback.** How the change is reverted if it goes wrong. This section is mandatory and must not say "revert the commit".
|
||||
- **Open questions.** Anything you could not decide without the user. Do not bury these; put them at the end so they are easy to find.
|
||||
|
||||
Operating rules:
|
||||
|
||||
- Read the existing spec templates in `docs/superpowers/specs/` BEFORE writing. Match their heading order, their table style, their voice.
|
||||
- NEVER edit production code in this mode. If you find yourself reaching for the edit tool on a non-spec file, STOP and ask the user to confirm the spec is approved.
|
||||
- NEVER skip the "Alternatives considered" section. The value of a spec is in the rejected paths, not the chosen one.
|
||||
- NEVER include "TODO" placeholders. If you do not know an answer, write the question in the Open Questions section.
|
||||
- After approval, the spec is the contract. If the implementation needs to deviate, the agent amends the spec FIRST, then edits code.
|
||||
|
||||
Output format:
|
||||
|
||||
- One file: `docs/superpowers/specs/<task-slug>.md`.
|
||||
- A 5-line summary in the chat reply: goal, blast radius (files touched), top open question, link to the file, and the literal Approval prompt.
|
||||
````
|
||||
|
||||
**How to invoke:**
|
||||
|
||||
```
|
||||
/draft-spec Add a /paste-image slash command that attaches a clipboard PNG to the next message
|
||||
/draft-spec <path-to-issue.md>
|
||||
```
|
||||
|
||||
After approval, the main agent picks up the spec and implements against it. If the implementation needs to deviate, the spec is amended first.
|
||||
|
||||
**Inspired by:**
|
||||
|
||||
- The repo's own `docs/superpowers/specs/` directory (read three existing specs before writing yours).
|
||||
- [GitLab's handbook RFC process](https://handbook.gitlab.com/handbook/communication/).
|
||||
- [AWS design docs](https://www.industrialempathy.com/posts/design-docs-at-google/).
|
||||
- [The Diataxis framework](https://diataxis.fr/) for distinguishing reference from explanation.
|
||||
|
||||
**See also:** entry #8 (Release Notes Generator) for the spec-to-release pipeline, entry #10 (Dependency Updater) for specs that introduce new dependencies.
|
||||
|
||||
|
||||
## 10. Dependency Updater
|
||||
|
||||
*One dep per commit, one commit per revert, never two changes in the same diff.*
|
||||
|
||||
The Dependency Updater keeps `go.mod`, `package.json`, and any other manifest current without ever landing a "miscellaneous bump" mega-commit. It discovers outdated dependencies, classifies each by changelog severity and blast radius, then proposes a plan that ranks updates by exploitability of the security advisories first, breaking changes last. It performs ONE update per commit so each commit is independently revertable. After every commit it runs the full test suite plus `go mod tidy`. On the first failing test it reverts the commit and stops the run, reporting the failing test name and the dependency that caused it. It NEVER bundles multiple updates into one commit, NEVER skips the test run for "trivial" patches, and NEVER force-pushes to undo a bad bump — it uses `git revert HEAD` so the history stays linear and bisectable.
|
||||
|
||||
**When to use it:**
|
||||
|
||||
- Weekly cron to keep the manifest current.
|
||||
- When Dependabot / Renovate is disabled or rate-limited on this repo.
|
||||
- When a CVE is published for something the repo uses and the team needs to land a fix TODAY.
|
||||
- When a major version of a core dependency (Go, a framework, a build tool) lands.
|
||||
- Before a release cut, to clear the backlog of pending updates.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Drop this into `.factory/droids/dependency-updater.md`:
|
||||
|
||||
````markdown
|
||||
---
|
||||
description: Keeps go.mod, package.json, and other manifests current. One dep per commit, full tests after each, halts on the first regression.
|
||||
globs: "go.mod, go.sum, package.json, package-lock.json, yarn.lock, pnpm-lock.yaml, Cargo.toml, requirements.txt, pyproject.toml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are the Dependency Updater. You keep the manifest current without ever landing a giant "misc bumps" commit. The cardinal rule: ONE dependency per commit. If two deps need to move, they get two commits.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Discover outdated deps. For Go, run `go list -u -m all`. For Node, run `npm outdated` (or the equivalent for pnpm/yarn/bun). For Python, run `pip list --outdated`. For Rust, run `cargo outdated` (if installed).
|
||||
2. Fetch the changelog for every outdated dep between the current version and the candidate. For Go modules, prefer `go.mod` history and the upstream `CHANGELOG.md` / GitHub releases page. NEVER bump without reading the diff.
|
||||
3. Build a plan. Order by:
|
||||
- Security advisories (highest priority, regardless of semver jump).
|
||||
- Patch updates (low blast radius).
|
||||
- Minor updates (medium blast radius, watch for deprecations).
|
||||
- Major updates (high blast radius, often need a spec — see entry #9).
|
||||
4. Present the plan to the user with a per-dep row: name, current, candidate, classification (security|patch|minor|major), estimated blast radius, link to the changelog. WAIT for approval before bumping.
|
||||
5. Execute one dep per commit. For each:
|
||||
a. Update the manifest entry.
|
||||
b. Run `go mod tidy` (or the manifest-locking equivalent for the ecosystem).
|
||||
c. Run the full test suite: `go test ./...` (or the project's chosen test command — check `AGENTS.md`).
|
||||
d. If tests pass, commit with message: `dep(<name>): v<old> -> v<new>` followed by a short body that quotes the most relevant changelog line.
|
||||
e. If tests fail, `git revert HEAD --no-edit`, then STOP the run. Report the failing test name, the dep that caused it, and the failing test output's last 30 lines.
|
||||
6. After all approved bumps land, run the test suite ONE MORE TIME end-to-end to catch inter-dep interactions, then report the day's net changes in a single summary table.
|
||||
|
||||
What you never do:
|
||||
|
||||
- NEVER bundle multiple deps into one commit. Each commit is a single named bump.
|
||||
- NEVER skip the test run, even for "obvious patch" updates.
|
||||
- NEVER bump a major version silently. A major version bump is a spec, not a one-line edit — refer to entry #9.
|
||||
- NEVER force-push. If you need to undo a bump, use `git revert HEAD` so the history stays linear.
|
||||
- NEVER edit source code to "make a dep bump pass". If the new version requires code changes, stop and report — that is a spec conversation, not a bump.
|
||||
- NEVER add a new dependency that is not already in the manifest. Adding a dep is a feature decision, not a maintenance task.
|
||||
|
||||
Output format:
|
||||
|
||||
- A plan table (markdown) BEFORE any commit, with columns: dep, current, candidate, classification, blast radius, changelog link.
|
||||
- A per-commit summary line during execution: `dep(name): vX.Y.Z -> vA.B.C — tests pass / tests fail (reverted)`.
|
||||
- A final summary table with the day's net changes: which deps moved, total commits, any rollbacks, the final test-suite result.
|
||||
````
|
||||
|
||||
**How to invoke:**
|
||||
|
||||
```
|
||||
/bump-deps # all patch + minor updates, no majors
|
||||
/bump-deps <package> # single dep, any semver jump
|
||||
/bump-deps --security-only # only deps with published CVEs
|
||||
```
|
||||
|
||||
**Inspired by:**
|
||||
|
||||
- [Dependabot](https://github.com/dependabot) and [Renovate](https://github.com/renovatebot/renovate) for the per-PR cadence.
|
||||
- The repo's `internal/update` package — read it for the existing update conventions.
|
||||
- The Go module release-notes practice of including upstream changelog links in commit bodies.
|
||||
- The "one commit per logical change" rule that underpins `git bisect` and `git revert` sanity.
|
||||
|
||||
**See also:** entry #9 (Spec Drafter) for major-version bumps, entry #4 (Security Audit) for the security classification, entry #5 (Migration Assistant) for framework-level migrations that span multiple deps.
|
||||
|
||||
---
|
||||
|
||||
That is the curated ten. The next section lists another thirty honorable mentions — useful, but redundant or narrower in scope than the entries above.
|
||||
|
||||
|
||||
## Honorable mentions
|
||||
|
||||
*Thirty more ideas worth knowing about, each one a real workflow but narrower than the curated ten above.*
|
||||
|
||||
### Codebase hygiene
|
||||
|
||||
- **License Header Auditor** — scans source for missing or non-canonical license headers, when a repo needs uniform legal markings.
|
||||
- **Dead Import Sweeper** — flags unused imports and unreachable code, when a codebase has accumulated drift.
|
||||
- **Magic-Number Spotter** — surfaces raw numeric literals that should be named constants, when intent needs to be clearer.
|
||||
- **Copyright-Line Normaliser** — aligns copyright lines to one year and entity format, when a repo is being audited.
|
||||
- **Gitignore Auditor** — checks that secrets, build outputs, and editor cruft are excluded, when onboarding a project.
|
||||
- **Large-File Preventer** — fails the build on commits over a configurable size, when binary drift recurs.
|
||||
|
||||
### Test and verification
|
||||
|
||||
- **Flaky-Test Detector** — re-runs a suspect test N times to estimate flake rate, when CI is unreliable.
|
||||
- **Coverage-Delta Reporter** — diffs coverage before and after a PR, when regressions are easy to miss.
|
||||
- **Fuzz-Harness Seeder** — generates an initial corpus for `go test -fuzz`, when a package lacks fuzz coverage.
|
||||
- **Race-Condition Reproducer** — runs a target under `-race` with stress loops, when concurrency bugs are suspected.
|
||||
- **Benchmark-Baseline Keeper** — stores benchmark output in a baseline file and flags drift, when perf regressions matter.
|
||||
- **Contract-Test Generator** — produces consumer-driven contract tests for an HTTP API, when teams ship in parallel.
|
||||
|
||||
### Repository intelligence
|
||||
|
||||
- **Repo-Map Refresh** — regenerates a curated directory tree, when docs drift from reality.
|
||||
- **Dependency-Graph Visualiser** — emits a `go mod graph` render as SVG or DOT, when a module's coupling is opaque.
|
||||
- **Churn-Heatmap Generator** — counts recent commits per file and renders a heatmap, when prioritising refactors.
|
||||
- **Contributor-Onboarding Packet** — assembles README, ARCHITECTURE, and first-issue pointers, for new contributors.
|
||||
- **Blame-Based On-Call Rotation** — derives a rotation from `git blame` ownership, when ownership data is the source of truth.
|
||||
- **README-Image Alt-Text Checker** — flags images missing alt text, when accessibility is a release blocker.
|
||||
|
||||
### Release and ops
|
||||
|
||||
- **Docker Image Slim-Down** — proposes multi-stage edits that shrink the final image, when container size hurts deploys.
|
||||
- **SBOM Generator** — emits a Software Bill of Materials in SPDX or CycloneDX, when supply-chain audits loom.
|
||||
- **Advisory-Feed Watcher** — opens issues when a watched CVE matches a dependency, when security monitoring is informal.
|
||||
- **Changelog Formatter** — normalises `CHANGELOG.md` to Keep-a-Changelog layout, when history is inconsistent.
|
||||
- **Semver Bumper** — proposes the next semver from commit history, when a release is overdue.
|
||||
- **Go-Release Tag-Verifier** — checks that tags match `go.mod`'s version line, when Go release tags have drifted.
|
||||
|
||||
### Workspace ergonomics
|
||||
|
||||
- **Env-Var Documenter** — lists every `os.Getenv` call and the keys it expects, when runtime config is undocumented.
|
||||
- **Dotfiles Linker** — symlinks a curated dotfile set into `$HOME`, when a new machine is being set up.
|
||||
- **Editor-Config Auditor** — confirms `.editorconfig` matches the team's actual settings, when style drift creeps in.
|
||||
- **AGENTS.md Drift-Detector** — diffs the repo's AGENTS.md against neighbouring projects, when shared guidance has forked.
|
||||
- **Slash-Command Discoverer** — prints every custom slash command and its trigger, when teammates forget what exists.
|
||||
- **Session-Summary Export** — turns a Zero session log into a Markdown brief, when work needs to be handed off.
|
||||
|
||||
The ten above are the highest-leverage picks; the thirty here are worth a project once the team has shipped the core workflow for a quarter. Promote any of them to a full entry using the same template as 01-10.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-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 {
|
||||
@@ -278,6 +281,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return runHooks(args[1:], stdout, stderr, deps)
|
||||
case "mcp":
|
||||
return runMCP(args[1:], stdout, stderr, deps)
|
||||
case "auth":
|
||||
return runAuth(args[1:], stdout, stderr, deps)
|
||||
case "sandbox":
|
||||
return runSandbox(args[1:], stdout, stderr, deps)
|
||||
case "update":
|
||||
@@ -427,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 {
|
||||
@@ -700,6 +717,7 @@ Commands:
|
||||
tools Scaffold and list local Zero plugin-tools
|
||||
hooks Inspect Zero hook configuration
|
||||
mcp Manage MCP backend settings
|
||||
auth Log in to model providers via OAuth
|
||||
sandbox Inspect sandbox policy and persistent grants
|
||||
update Check for Zero CLI updates
|
||||
worktrees Prepare isolated git worktrees
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
|
||||
// additive and independent of `zero mcp oauth` (MCP server auth), which is
|
||||
// unchanged.
|
||||
func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeExecUsageError(stderr, "auth subcommand required. Use `zero auth status`.")
|
||||
}
|
||||
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
if err := writeAuthHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
switch args[0] {
|
||||
case "login":
|
||||
return runAuthLogin(args[1:], stdout, stderr, deps)
|
||||
case "logout":
|
||||
return runAuthLogout(args[1:], stdout, stderr, deps)
|
||||
case "status":
|
||||
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
|
||||
json bool
|
||||
device bool
|
||||
watch bool
|
||||
scopes []string
|
||||
help bool
|
||||
}
|
||||
|
||||
func parseAuthArgs(sub string, args []string) (authArgs, error) {
|
||||
var parsed authArgs
|
||||
addScope := func(scope string) error {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return fmt.Errorf("--scope requires a non-empty value")
|
||||
}
|
||||
parsed.scopes = append(parsed.scopes, scope)
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "-h" || arg == "--help" || arg == "help":
|
||||
parsed.help = true
|
||||
case arg == "--json":
|
||||
parsed.json = true
|
||||
case arg == "--device":
|
||||
parsed.device = true
|
||||
case arg == "--watch":
|
||||
parsed.watch = true
|
||||
case arg == "--scope":
|
||||
if i+1 >= len(args) {
|
||||
return authArgs{}, fmt.Errorf("--scope requires a value")
|
||||
}
|
||||
i++
|
||||
if err := addScope(args[i]); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "--scope="):
|
||||
if err := addScope(strings.TrimPrefix(arg, "--scope=")); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
return authArgs{}, fmt.Errorf("unknown flag %q", arg)
|
||||
default:
|
||||
parsed.positional = append(parsed.positional, arg)
|
||||
}
|
||||
}
|
||||
if parsed.help {
|
||||
return parsed, nil // help short-circuits flag validation
|
||||
}
|
||||
if err := validateAuthFlags(sub, parsed); err != nil {
|
||||
return authArgs{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// validateAuthFlags rejects flags a subcommand does not accept, so an ambiguous
|
||||
// invocation fails fast instead of silently ignoring a flag.
|
||||
func validateAuthFlags(sub string, a authArgs) error {
|
||||
allowed := map[string]map[string]bool{
|
||||
"login": {"device": true, "scope": true},
|
||||
"logout": {"json": true},
|
||||
"status": {"json": true},
|
||||
"refresh": {"watch": true},
|
||||
}[sub]
|
||||
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
|
||||
if a.json && !allowed["json"] {
|
||||
return bad("--json")
|
||||
}
|
||||
if a.device && !allowed["device"] {
|
||||
return bad("--device")
|
||||
}
|
||||
if a.watch && !allowed["watch"] {
|
||||
return bad("--watch")
|
||||
}
|
||||
if len(a.scopes) > 0 && !allowed["scope"] {
|
||||
return bad("--scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newAuthManager builds an oauth.Manager backed by the file store, printing the
|
||||
// authorization URL / device code to stdout. The store path honors
|
||||
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it. Setting
|
||||
// ZERO_OAUTH_STORAGE=encrypted-file selects the AES-256-GCM encrypted-at-rest
|
||||
// backend (a per-user secret is created beside the token file).
|
||||
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
|
||||
// Validate ZERO_OAUTH_STORAGE up front: a mistyped value must fail fast rather
|
||||
// than silently change the backend. Empty = default (plaintext 0600 file);
|
||||
// "encrypted-file" = AES-256-GCM; "keyring" = the OS keyring.
|
||||
storage := strings.ToLower(strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")))
|
||||
switch storage {
|
||||
case "", "file", "encrypted-file", "keyring":
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: file, encrypted-file, keyring)", storage)
|
||||
}
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{
|
||||
Now: deps.now,
|
||||
Storage: storage,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oauth.NewManager(oauth.ManagerOptions{
|
||||
Store: store,
|
||||
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||
Now: deps.now,
|
||||
Out: out,
|
||||
// The opener prints the URL so headless shells can copy it; the URL
|
||||
// carries no token material. A real browser launch is intentionally not
|
||||
// performed (the sandbox/headless contexts make printing the safer default).
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
}
|
||||
|
||||
func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("login", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
status, err := manager.Login(context.Background(), oauth.LoginOptions{
|
||||
Provider: parsed.positional[0],
|
||||
Device: parsed.device,
|
||||
ExtraScopes: parsed.scopes,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("logout", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth logout <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
removed, err := manager.Logout(provider)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Provider string `json:"provider"`
|
||||
Removed bool `json:"removed"`
|
||||
}{Provider: provider, Removed: removed}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
|
||||
if removed {
|
||||
msg = fmt.Sprintf("Logged out of %s.\n", provider)
|
||||
}
|
||||
if _, err := fmt.Fprint(stdout, msg); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthStatus(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("status", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) > 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth status [provider]")
|
||||
}
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
statuses, err := manager.StatusAll()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if len(parsed.positional) == 1 {
|
||||
statuses = filterAuthStatuses(statuses, parsed.positional[0])
|
||||
}
|
||||
if parsed.json {
|
||||
payload := struct {
|
||||
Logins []oauth.Status `json:"logins"`
|
||||
}{Logins: statuses}
|
||||
if err := writePrettyJSON(stdout, payload); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, oauth.FormatStatuses(statuses)); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runAuthRefresh(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
parsed, err := parseAuthArgs("refresh", args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if parsed.help {
|
||||
_ = writeAuthHelp(stdout)
|
||||
return exitSuccess
|
||||
}
|
||||
if len(parsed.positional) != 1 {
|
||||
return writeExecUsageError(stderr, "usage: zero auth refresh <provider>")
|
||||
}
|
||||
provider := parsed.positional[0]
|
||||
manager, err := newAuthManager(deps, stdout)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
key := oauth.ProviderKey(provider)
|
||||
if parsed.watch {
|
||||
return runAuthRefreshWatch(manager, key, provider, stdout, stderr)
|
||||
}
|
||||
if _, err := manager.Handle401(context.Background(), key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if _, err := fmt.Fprintf(stdout, "Refreshed OAuth token for %s.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runAuthRefreshWatch keeps a provider's token fresh in the foreground until
|
||||
// interrupted. This is the opt-in proactive-refresh scheduler surface (for a
|
||||
// long-running external process that reads the token file). It validates a
|
||||
// refreshable token exists first, then schedules refreshes before each expiry.
|
||||
func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io.Writer, stderr io.Writer) int {
|
||||
ctx, stop := signalContext()
|
||||
defer stop()
|
||||
// Validate (and refresh-if-needed) once up front so a missing/non-refreshable
|
||||
// token fails fast instead of silently watching nothing.
|
||||
if _, err := manager.GetFresh(ctx, key); err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
scheduler := oauth.NewRefreshScheduler()
|
||||
scheduler.Start(ctx, manager, key)
|
||||
defer scheduler.Stop()
|
||||
if _, err := fmt.Fprintf(stdout, "Watching %s — refreshing before expiry. Press Ctrl+C to stop.\n", provider); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
<-ctx.Done()
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
|
||||
want := oauth.ProviderKey(provider)
|
||||
filtered := make([]oauth.Status, 0, 1)
|
||||
for _, st := range statuses {
|
||||
if st.Key == want {
|
||||
filtered = append(filtered, st)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func writeAuthHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero auth <command>
|
||||
|
||||
Commands:
|
||||
login <provider> [--device] [--scope <scope>] Log in to a provider via OAuth
|
||||
logout <provider> Delete a provider's stored login
|
||||
status [provider] Show login presence/expiry (never the token)
|
||||
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
|
||||
openrouter Log in to OpenRouter in the browser; mints an API key
|
||||
|
||||
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
|
||||
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
|
||||
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
|
||||
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
|
||||
below. For a custom provider named <name>, set:
|
||||
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
|
||||
ZERO_OAUTH_<NAME>_DEVICE_URL ZERO_OAUTH_<NAME>_ISSUER_URL (for discovery)
|
||||
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)
|
||||
--watch Keep the token fresh in the foreground (refresh only)
|
||||
--json Print result as JSON (status/logout)
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
func withAuthStore(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
|
||||
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
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
|
||||
t.Fatalf("status output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
|
||||
path := withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
|
||||
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
|
||||
}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
|
||||
t.Fatalf("status should show provider + account: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "super-secret") {
|
||||
t.Fatalf("status leaked token material: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLogoutNothing(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
|
||||
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
|
||||
t.Fatalf("logout output = %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginValidation(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
// Missing provider.
|
||||
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login with no provider should fail")
|
||||
}
|
||||
// --json is rejected for the interactive login.
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("login --json should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthLoginUnknownProvider(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("unknown provider login should fail")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "not configured") {
|
||||
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRefreshNoToken(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Fatal("refresh with no stored token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAuthRejectsWrongFlags(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
cases := [][]string{
|
||||
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
|
||||
{"auth", "login", "demo", "--json"}, // json not for interactive login
|
||||
{"auth", "status", "demo", "--device"}, // device is login-only
|
||||
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
|
||||
{"auth", "refresh", "demo", "--json"}, // json not for refresh
|
||||
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
|
||||
}
|
||||
for _, args := range cases {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
|
||||
t.Errorf("args %v should be rejected, got success", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+359
-18
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
"github.com/Gitlawb/zero/internal/daemon/remote"
|
||||
)
|
||||
|
||||
// runDaemon dispatches the `zero daemon ...` subcommands. The daemon supervises a
|
||||
@@ -35,6 +36,10 @@ func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int
|
||||
return runDaemonRun(rest, stdout, stderr)
|
||||
case "attach":
|
||||
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:
|
||||
@@ -53,6 +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> [--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). --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. Use the link's remote path as --cwd to run against a linked repo.
|
||||
`)
|
||||
return code
|
||||
}
|
||||
@@ -212,6 +231,7 @@ func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session, cwd, prompt := "", "", ""
|
||||
var remoteFlags remoteDialFlags
|
||||
var forward []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
@@ -225,6 +245,38 @@ func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case a == "--session":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
@@ -261,13 +313,9 @@ func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if prompt == "" && len(forward) == 0 {
|
||||
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "zero daemon is not running (start it with `zero daemon start`)", exitCrash)
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Run(session, cwd, prompt, forward, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
@@ -278,16 +326,57 @@ func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
|
||||
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session := ""
|
||||
var remoteFlags remoteDialFlags
|
||||
extra := 0
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if session == "" {
|
||||
case a == "--remote":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--remote requires a value")
|
||||
}
|
||||
remoteFlags.Addr = v
|
||||
case strings.HasPrefix(a, "--remote="):
|
||||
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
|
||||
case a == "--token":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--token requires a value")
|
||||
}
|
||||
remoteFlags.Token = v
|
||||
case strings.HasPrefix(a, "--token="):
|
||||
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
|
||||
case a == "--ca-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--ca-cert requires a value")
|
||||
}
|
||||
remoteFlags.CACert = v
|
||||
case strings.HasPrefix(a, "--ca-cert="):
|
||||
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
|
||||
case a == "--server-name":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--server-name requires a value")
|
||||
}
|
||||
remoteFlags.ServerName = v
|
||||
case strings.HasPrefix(a, "--server-name="):
|
||||
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
|
||||
case session == "":
|
||||
session = a
|
||||
continue
|
||||
default:
|
||||
extra++
|
||||
}
|
||||
extra++
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
|
||||
@@ -295,13 +384,9 @@ func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if extra > 0 {
|
||||
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "zero daemon is not running (start it with `zero daemon start`)", exitCrash)
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
@@ -310,6 +395,262 @@ func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// daemonDialError tailors the connect-failure message for local vs remote.
|
||||
func daemonDialError(flags remoteDialFlags, err error) string {
|
||||
if strings.TrimSpace(flags.Addr) != "" {
|
||||
return "failed to reach remote daemon at " + flags.Addr + ": " + err.Error()
|
||||
}
|
||||
return "zero daemon is not running (start it with `zero daemon start`)"
|
||||
}
|
||||
|
||||
// runDaemonServeRemote starts the local daemon plus an opt-in, TLS-only network
|
||||
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
|
||||
// start without a cert/key pair and a token from the environment.
|
||||
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
addr, certFile, keyFile, bundleDir := "", "", "", ""
|
||||
minVersion, maxConns := 0, 0
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
value := func() (string, bool) {
|
||||
if i+1 >= len(args) {
|
||||
return "", false
|
||||
}
|
||||
i++
|
||||
return args[i], true
|
||||
}
|
||||
switch {
|
||||
case a == "-h" || a == "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
case a == "--addr":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--addr requires a value")
|
||||
}
|
||||
addr = v
|
||||
case strings.HasPrefix(a, "--addr="):
|
||||
addr = strings.TrimPrefix(a, "--addr=")
|
||||
case a == "--tls-cert":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-cert requires a value")
|
||||
}
|
||||
certFile = v
|
||||
case strings.HasPrefix(a, "--tls-cert="):
|
||||
certFile = strings.TrimPrefix(a, "--tls-cert=")
|
||||
case a == "--tls-key":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--tls-key requires a value")
|
||||
}
|
||||
keyFile = v
|
||||
case strings.HasPrefix(a, "--tls-key="):
|
||||
keyFile = strings.TrimPrefix(a, "--tls-key=")
|
||||
case a == "--min-version":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--min-version requires a value")
|
||||
}
|
||||
minVersion = atoiOrZero(v)
|
||||
case a == "--max-conns":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
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))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return writeExecUsageError(stderr, "daemon serve-remote requires --addr <host:port>")
|
||||
}
|
||||
// Fail closed: TLS cert/key + a bearer token are mandatory.
|
||||
tlsConfig, err := remote.ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
token, err := remote.TokenFromEnv()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
auth, err := remote.NewTokenAuthenticator(token)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
bridge, err := remote.NewBridge(remote.BridgeOptions{
|
||||
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
|
||||
})
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
|
||||
// Serve the local control socket too, so local clients keep working.
|
||||
go func() {
|
||||
if serveErr := srv.Serve(); serveErr != nil {
|
||||
logf("local serve error: " + serveErr.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr)
|
||||
select {
|
||||
case <-sigCh:
|
||||
srv.Shutdown()
|
||||
_ = bridge.Close()
|
||||
<-serveErr // wait for the accept loop to unwind
|
||||
return exitSuccess
|
||||
case err := <-serveErr:
|
||||
// Bind/serve failed before any signal (e.g. address in use).
|
||||
srv.Shutdown()
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Addr string
|
||||
Token string
|
||||
CACert string
|
||||
ServerName string
|
||||
}
|
||||
|
||||
// dialForCLI returns a daemon.Client for either the local socket (Addr empty) or
|
||||
// a remote bridge (Addr set). For remote, the token falls back to the env.
|
||||
func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
|
||||
if strings.TrimSpace(flags.Addr) == "" {
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return daemon.Dial(paths.Socket)
|
||||
}
|
||||
token := strings.TrimSpace(flags.Token)
|
||||
if token == "" {
|
||||
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
|
||||
}
|
||||
return remote.DialRemote(remote.RemoteConfig{
|
||||
Address: flags.Addr,
|
||||
Token: token,
|
||||
CACertFile: flags.CACert,
|
||||
ServerName: flags.ServerName,
|
||||
})
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
n := 0
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
if r < '0' || r > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// daemonReachable reports whether a daemon is accepting connections (a successful
|
||||
// handshake). Used for the single-instance check and start-up wait.
|
||||
func daemonReachable(paths daemon.Paths) bool {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -26,6 +26,19 @@ func Dial(socketPath string) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewClientConn completes the control handshake on an already-connected
|
||||
// transport (e.g. a TLS connection the remote bridge has authenticated) and
|
||||
// returns a Client ready for Run/Attach/Status. It takes ownership of conn and
|
||||
// closes it on handshake failure.
|
||||
func NewClientConn(conn net.Conn) (*Client, error) {
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) handshake() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlHello, Version: ProtoVersion}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package remote adds an OPT-IN, TLS-only network bridge on top of zero's local
|
||||
// daemon (internal/daemon). It lets a remote client drive the SAME daemon
|
||||
// SessionManager/Pool over the network, behind bearer-token authentication and a
|
||||
// protocol-version floor. The local Unix-socket daemon is unchanged and remains
|
||||
// the default; nothing here activates unless `zero daemon serve-remote` is run.
|
||||
//
|
||||
// Security (fail closed):
|
||||
// - TLS is mandatory; the bridge refuses to serve without a cert/key.
|
||||
// - A connection is authenticated BEFORE any control frame is dispatched; a
|
||||
// failed/absent token closes the connection (after a small backoff) and no
|
||||
// session frame is ever processed.
|
||||
// - Tokens are compared in constant time and are never logged.
|
||||
// - The control-frame size cap and version negotiation from internal/daemon are
|
||||
// reused unchanged; oversize/old-version handshakes are rejected.
|
||||
// - A remote-driven session runs through the same daemon dispatch, so it stays
|
||||
// under the same sandbox + risk model — remote never bypasses local controls.
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// Env vars the bridge reads for its bearer token.
|
||||
const (
|
||||
EnvToken = "ZERO_DAEMON_REMOTE_TOKEN"
|
||||
EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE"
|
||||
)
|
||||
|
||||
// ErrUnauthorized is returned when a token does not match.
|
||||
var ErrUnauthorized = errors.New("remote: unauthorized")
|
||||
|
||||
// Authenticator verifies a bearer token presented by a remote client.
|
||||
type Authenticator interface {
|
||||
Authenticate(token string) error
|
||||
}
|
||||
|
||||
// TokenAuthenticator compares a presented token against a fixed secret in
|
||||
// constant time.
|
||||
type TokenAuthenticator struct {
|
||||
token string
|
||||
}
|
||||
|
||||
// NewTokenAuthenticator builds a token authenticator, refusing an empty secret
|
||||
// (fail closed — a bridge must never accept everyone).
|
||||
func NewTokenAuthenticator(token string) (*TokenAuthenticator, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty auth token is required")
|
||||
}
|
||||
return &TokenAuthenticator{token: token}, nil
|
||||
}
|
||||
|
||||
// Authenticate reports nil when token matches the configured secret.
|
||||
func (a *TokenAuthenticator) Authenticate(token string) error {
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(a.token)) == 1 {
|
||||
return nil
|
||||
}
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
// TokenFromEnv resolves the bridge token from EnvToken, or a file named by
|
||||
// EnvTokenFile. It never logs the token.
|
||||
func TokenFromEnv() (string, error) {
|
||||
if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" {
|
||||
return t, nil
|
||||
}
|
||||
if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("remote: read token file: %w", err)
|
||||
}
|
||||
t := strings.TrimSpace(string(data))
|
||||
if t == "" {
|
||||
return "", errors.New("remote: token file is empty")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile)
|
||||
}
|
||||
|
||||
// Attestation is an optional post-token hook (e.g. workload attestation). The
|
||||
// default is a no-op; a deployment can supply a stricter implementation.
|
||||
type Attestation interface {
|
||||
Verify(meta map[string]string) error
|
||||
}
|
||||
|
||||
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.
|
||||
type authResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthRequest(w io.Writer, req authRequest) error {
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthRequest(r io.Reader) (authRequest, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authRequest{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authRequest{}, errors.New("remote: expected auth frame")
|
||||
}
|
||||
var req authRequest
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return authRequest{}, fmt.Errorf("remote: decode auth request: %w", err)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func writeAuthResponse(w io.Writer, resp authResponse) error {
|
||||
payload, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
|
||||
}
|
||||
|
||||
func readAuthResponse(r io.Reader) (authResponse, error) {
|
||||
kind, payload, err := daemon.ReadFrame(r)
|
||||
if err != nil {
|
||||
return authResponse{}, err
|
||||
}
|
||||
if kind != daemon.KindCtrl {
|
||||
return authResponse{}, errors.New("remote: expected auth response frame")
|
||||
}
|
||||
var resp authResponse
|
||||
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||
return authResponse{}, fmt.Errorf("remote: decode auth response: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenAuthenticator(t *testing.T) {
|
||||
if _, err := NewTokenAuthenticator(" "); err == nil {
|
||||
t.Fatal("empty token must be rejected (fail closed)")
|
||||
}
|
||||
a, err := NewTokenAuthenticator("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenAuthenticator: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("s3cret"); err != nil {
|
||||
t.Fatalf("matching token should authenticate: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("wrong"); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("mismatch err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
if err := a.Authenticate(""); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("empty presented token err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFromEnv(t *testing.T) {
|
||||
// Clear both so the no-config path errors.
|
||||
t.Setenv(EnvToken, "")
|
||||
t.Setenv(EnvTokenFile, "")
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("TokenFromEnv with neither var set must error")
|
||||
}
|
||||
// Direct env.
|
||||
t.Setenv(EnvToken, "from-env")
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-env" {
|
||||
t.Fatalf("TokenFromEnv(env) = %q, %v", tok, err)
|
||||
}
|
||||
// File (env takes precedence, so clear env first).
|
||||
t.Setenv(EnvToken, "")
|
||||
file := filepath.Join(t.TempDir(), "tok")
|
||||
if err := os.WriteFile(file, []byte(" from-file\n"), 0o600); err != nil {
|
||||
t.Fatalf("write token file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvTokenFile, file)
|
||||
if tok, err := TokenFromEnv(); err != nil || tok != "from-file" {
|
||||
t.Fatalf("TokenFromEnv(file) = %q, %v", tok, err)
|
||||
}
|
||||
// Empty file fails closed.
|
||||
if err := os.WriteFile(file, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("rewrite token file: %v", err)
|
||||
}
|
||||
if _, err := TokenFromEnv(); err == nil {
|
||||
t.Fatal("empty token file must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTLSConfigRequiresCertKey(t *testing.T) {
|
||||
if _, err := ServerTLSConfig("", ""); err == nil {
|
||||
t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)")
|
||||
}
|
||||
if _, err := ServerTLSConfig("/nope/cert.pem", "/nope/key.pem"); err == nil {
|
||||
t.Fatal("ServerTLSConfig must error on unreadable cert/key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
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
|
||||
// control dispatch for each one.
|
||||
type Bridge struct {
|
||||
server *daemon.Server
|
||||
auth Authenticator
|
||||
attest Attestation
|
||||
minVersion int
|
||||
handshakeTimeout time.Duration
|
||||
authFailDelay time.Duration
|
||||
bundleDir string
|
||||
maxBundleBytes int64
|
||||
log func(string)
|
||||
sem chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// BridgeOptions configures a Bridge.
|
||||
type BridgeOptions struct {
|
||||
// Server is the local daemon to drive (required).
|
||||
Server *daemon.Server
|
||||
// Authenticator verifies bearer tokens (required).
|
||||
Authenticator Authenticator
|
||||
// Attestation is an optional post-token hook; nil => no-op.
|
||||
Attestation Attestation
|
||||
// MinVersion is the lowest control-protocol version accepted; 0 =>
|
||||
// daemon.ProtoVersion.
|
||||
MinVersion int
|
||||
// MaxConnections bounds concurrent remote connections; 0 => default.
|
||||
MaxConnections int
|
||||
// HandshakeTimeout bounds the auth handshake; 0 => default.
|
||||
HandshakeTimeout time.Duration
|
||||
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
|
||||
AuthFailDelay time.Duration
|
||||
// BundleDir is the directory under which uploaded git bundles are extracted
|
||||
// into per-link working trees. Empty disables bundle uploads entirely (a
|
||||
// bundle-mode connection is then refused — opt-in, fail closed).
|
||||
BundleDir string
|
||||
// MaxBundleBytes caps a single uploaded bundle; 0 => default.
|
||||
MaxBundleBytes int64
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// NewBridge validates options and builds a Bridge.
|
||||
func NewBridge(opts BridgeOptions) (*Bridge, error) {
|
||||
if opts.Server == nil {
|
||||
return nil, errors.New("remote: bridge requires a daemon server")
|
||||
}
|
||||
if opts.Authenticator == nil {
|
||||
return nil, errors.New("remote: bridge requires an authenticator")
|
||||
}
|
||||
minVersion := opts.MinVersion
|
||||
if minVersion <= 0 {
|
||||
minVersion = daemon.ProtoVersion
|
||||
}
|
||||
maxConns := opts.MaxConnections
|
||||
if maxConns <= 0 {
|
||||
maxConns = defaultMaxConnections
|
||||
}
|
||||
handshakeTimeout := opts.HandshakeTimeout
|
||||
if handshakeTimeout <= 0 {
|
||||
handshakeTimeout = defaultHandshakeTimeout
|
||||
}
|
||||
authFailDelay := opts.AuthFailDelay
|
||||
switch {
|
||||
case authFailDelay < 0:
|
||||
authFailDelay = 0
|
||||
case authFailDelay == 0:
|
||||
authFailDelay = defaultAuthFailDelay
|
||||
}
|
||||
attest := opts.Attestation
|
||||
if attest == nil {
|
||||
attest = noopAttestation{}
|
||||
}
|
||||
maxBundleBytes := opts.MaxBundleBytes
|
||||
if maxBundleBytes <= 0 {
|
||||
maxBundleBytes = defaultMaxBundleBytes
|
||||
}
|
||||
return &Bridge{
|
||||
server: opts.Server,
|
||||
auth: opts.Authenticator,
|
||||
attest: attest,
|
||||
minVersion: minVersion,
|
||||
handshakeTimeout: handshakeTimeout,
|
||||
authFailDelay: authFailDelay,
|
||||
bundleDir: strings.TrimSpace(opts.BundleDir),
|
||||
maxBundleBytes: maxBundleBytes,
|
||||
log: opts.Log,
|
||||
sem: make(chan struct{}, maxConns),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *Bridge) logf(format string, args ...any) {
|
||||
if b.log != nil {
|
||||
b.log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServeTLS binds a TLS listener on addr and serves until the listener
|
||||
// is closed (via Close). It refuses to serve without a TLS config (no plaintext
|
||||
// remote bridge).
|
||||
func (b *Bridge) ListenAndServeTLS(addr string, tlsConfig *tls.Config) error {
|
||||
if tlsConfig == nil {
|
||||
return errors.New("remote: TLS config is required; refusing to serve a plaintext remote bridge")
|
||||
}
|
||||
listener, err := tls.Listen("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote: listen %s: %w", addr, err)
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.listener = listener
|
||||
b.mu.Unlock()
|
||||
return b.Serve(listener)
|
||||
}
|
||||
|
||||
// Close stops the listener started by ListenAndServeTLS, causing Serve to
|
||||
// return. Safe to call before serving or more than once.
|
||||
func (b *Bridge) Close() error {
|
||||
b.mu.Lock()
|
||||
listener := b.listener
|
||||
b.listener = nil // clear so a repeat Close is a no-op, not a closed-listener error
|
||||
b.mu.Unlock()
|
||||
if listener != nil {
|
||||
return listener.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Serve accepts connections from listener until it returns an error (e.g. it is
|
||||
// closed). Each connection is authenticated, then handed to the daemon's control
|
||||
// dispatch. Connections beyond MaxConnections are refused immediately.
|
||||
func (b *Bridge) Serve(listener net.Listener) error {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case b.sem <- struct{}{}:
|
||||
default:
|
||||
// At capacity: refuse fast (fail closed) rather than queueing.
|
||||
b.logf("remote: refused connection (at capacity)")
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-b.sem }()
|
||||
b.handle(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// handle authenticates conn then drives the daemon dispatch. It always closes
|
||||
// conn (directly on a failed handshake, or via ServeConn on success).
|
||||
func (b *Bridge) handle(conn net.Conn) {
|
||||
// Bound the auth handshake so a stalled peer cannot hold a connection slot.
|
||||
_ = conn.SetDeadline(time.Now().Add(b.handshakeTimeout))
|
||||
req, err := readAuthRequest(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if req.Version < b.minVersion {
|
||||
b.deny(conn, "unsupported protocol version")
|
||||
return
|
||||
}
|
||||
if err := b.auth.Authenticate(req.Token); err != nil {
|
||||
b.deny(conn, "unauthorized")
|
||||
return
|
||||
}
|
||||
if err := b.attest.Verify(req.Meta); err != nil {
|
||||
b.deny(conn, "attestation failed")
|
||||
return
|
||||
}
|
||||
// Resolve the connection mode before accepting it. Validating here (before the
|
||||
// success response) lets the bridge fail closed: an unknown mode, or a bundle
|
||||
// upload when bundle transfer is disabled, is denied without side effects.
|
||||
mode := req.Mode
|
||||
if mode == "" {
|
||||
mode = ModeSession
|
||||
}
|
||||
switch mode {
|
||||
case ModeSession:
|
||||
case ModeBundle:
|
||||
if b.bundleDir == "" {
|
||||
b.deny(conn, "bundle transfer not enabled")
|
||||
return
|
||||
}
|
||||
default:
|
||||
b.deny(conn, "unsupported mode")
|
||||
return
|
||||
}
|
||||
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
// Clear the handshake deadline: a session may stream, and a bundle upload may
|
||||
// transfer many megabytes, for a long time.
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
switch mode {
|
||||
case ModeBundle:
|
||||
b.handleBundle(conn) // receives + extracts the bundle, then closes conn
|
||||
default:
|
||||
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
|
||||
}
|
||||
}
|
||||
|
||||
// deny rejects an unauthenticated connection after a small backoff (to slow
|
||||
// brute force). The reason is generic and never includes any token material.
|
||||
func (b *Bridge) deny(conn net.Conn, message string) {
|
||||
if b.authFailDelay > 0 {
|
||||
time.Sleep(b.authFailDelay)
|
||||
}
|
||||
_ = writeAuthResponse(conn, authResponse{OK: false, Message: message})
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// ServerTLSConfig builds a server TLS config from a cert/key pair, refusing if
|
||||
// either is missing (TLS is mandatory for the remote bridge).
|
||||
func ServerTLSConfig(certFile, keyFile string) (*tls.Config, error) {
|
||||
certFile = strings.TrimSpace(certFile)
|
||||
keyFile = strings.TrimSpace(keyFile)
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, errors.New("remote: a TLS cert and key are required for serve-remote")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: load TLS keypair: %w", err)
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
// ---- fake daemon worker (the daemon's test fakes are unexported) -------------
|
||||
|
||||
type fakeLines struct{ ch <-chan string }
|
||||
|
||||
func (l fakeLines) Next() (string, bool, error) {
|
||||
line, ok := <-l.ch
|
||||
return line, ok, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
ch chan string
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() daemon.Lines { return fakeLines{ch: w.ch} }
|
||||
func (w *fakeWorker) Wait() (int, error) { return 0, nil }
|
||||
func (w *fakeWorker) Kill() error { return nil }
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// staticLauncher emits the given lines then ends the stream.
|
||||
func staticLauncher(lines ...string) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string, len(lines))
|
||||
for _, l := range lines {
|
||||
ch <- l
|
||||
}
|
||||
close(ch)
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// blockingLauncher returns a worker whose stream stays open until release is
|
||||
// closed, so a session (and its bridge connection slot) is held open.
|
||||
func blockingLauncher(release <-chan struct{}) daemon.Launcher {
|
||||
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
<-release
|
||||
close(ch)
|
||||
}()
|
||||
return &fakeWorker{ch: ch, pid: 1}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newBridgeServer(t *testing.T, launcher daemon.Launcher) *daemon.Server {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pool, err := daemon.NewPool(daemon.PoolOptions{Size: 4, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := daemon.NewServer(daemon.ServerOptions{
|
||||
Paths: daemon.Paths{Socket: filepath.Join(dir, "s.sock"), Lock: filepath.Join(dir, "s.lock"), Status: filepath.Join(dir, "s.status")},
|
||||
Manager: mgr,
|
||||
Pool: pool,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
return srv
|
||||
}
|
||||
|
||||
// genTestCert writes a self-signed cert/key (valid for 127.0.0.1) to temp files.
|
||||
func genTestCert(t *testing.T) (certFile, keyFile string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "zero-remote-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
certFile = filepath.Join(dir, "cert.pem")
|
||||
keyFile = filepath.Join(dir, "key.pem")
|
||||
certOut, _ := os.Create(certFile)
|
||||
_ = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
_ = certOut.Close()
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
keyOut, _ := os.Create(keyFile)
|
||||
_ = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
_ = keyOut.Close()
|
||||
return certFile, keyFile
|
||||
}
|
||||
|
||||
// startBridge starts a TLS bridge on 127.0.0.1:0 and returns its address + cert.
|
||||
func startBridge(t *testing.T, srv *daemon.Server, opts BridgeOptions) (addr, caCert string) {
|
||||
t.Helper()
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
opts.Server = srv
|
||||
bridge, err := NewBridge(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tlsLn := tls.NewListener(ln, tlsConfig)
|
||||
go func() { _ = bridge.Serve(tlsLn) }()
|
||||
t.Cleanup(func() { _ = tlsLn.Close() })
|
||||
return ln.Addr().String(), certFile
|
||||
}
|
||||
|
||||
func TestBridgeRunStatusRoundTrip(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
client, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote: %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("sess-1", "", "hello", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 2 || got[0] != `{"type":"event","seq":1}` {
|
||||
t.Fatalf("run output = %v", got)
|
||||
}
|
||||
|
||||
// Status over a fresh remote connection (same protocol as local).
|
||||
statusClient, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
_ = statusClient.Close()
|
||||
if report == nil || report.PID == 0 {
|
||||
t.Fatalf("status report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsBadToken(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("correct")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca})
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("DialRemote(bad token) err = %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRejectsLowVersion(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
// Require a version higher than the client speaks (daemon.ProtoVersion).
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MinVersion: daemon.ProtoVersion + 1, AuthFailDelay: -1})
|
||||
|
||||
_, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err == nil {
|
||||
t.Fatal("DialRemote with a below-min version must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRequiresVerifiableCert(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, _ := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
// No CA configured + a self-signed server cert => verification must fail
|
||||
// (NEVER InsecureSkipVerify).
|
||||
if _, err := DialRemote(RemoteConfig{Address: addr, Token: "tok"}); err == nil {
|
||||
t.Fatal("DialRemote must reject an untrusted self-signed cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSRequiresConfig(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher())
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
bridge, _ := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err := bridge.ListenAndServeTLS("127.0.0.1:0", nil); err == nil {
|
||||
t.Fatal("ListenAndServeTLS(nil) must refuse to serve plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAndServeTLSServesAndCloses(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
certFile, keyFile := genTestCert(t)
|
||||
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ServerTLSConfig: %v", err)
|
||||
}
|
||||
bridge, err := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBridge: %v", err)
|
||||
}
|
||||
// Bind an ephemeral port, discover it, then serve via ListenAndServeTLS by
|
||||
// passing the same addr (it rebinds) — instead we bind here to learn the port
|
||||
// and serve through the public entry on that exact address.
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("probe listen: %v", err)
|
||||
}
|
||||
addr := probe.Addr().String()
|
||||
_ = probe.Close()
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
|
||||
|
||||
// Wait until it accepts a connection, then drive one run.
|
||||
var client *daemon.Client
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
client, err = DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: certFile, Timeout: 300 * time.Millisecond})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("bridge never came up: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
var got []string
|
||||
if err := client.Run("s", "", "hi", nil, func(l string) { got = append(got, l) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = client.Close()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d lines, want 1", len(got))
|
||||
}
|
||||
// Close stops the accept loop; a second Close is a no-op (not an error).
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("second Close should be a no-op, got: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-serveErr: // ListenAndServeTLS returned after Close
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("ListenAndServeTLS did not return after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeResumeAfterReconnect(t *testing.T) {
|
||||
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
|
||||
|
||||
// First connection runs the session to completion.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
if err := c1.Run("sess-resume", "", "hi", nil, func(string) {}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
_ = c1.Close()
|
||||
|
||||
// A new connection re-attaches and replays the buffered session output —
|
||||
// the dropped first connection did not lose the session.
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 2: %v", err)
|
||||
}
|
||||
defer c2.Close()
|
||||
var replayed []string
|
||||
if err := c2.Attach("sess-resume", func(l string) { replayed = append(replayed, l) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
if len(replayed) != 2 {
|
||||
t.Fatalf("re-attach replayed %d lines, want 2", len(replayed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeConnectionCap(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
srv := newBridgeServer(t, blockingLauncher(release))
|
||||
auth, _ := NewTokenAuthenticator("tok")
|
||||
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MaxConnections: 1})
|
||||
|
||||
// First connection holds the only slot with a blocking run.
|
||||
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
|
||||
if err != nil {
|
||||
t.Fatalf("DialRemote 1: %v", err)
|
||||
}
|
||||
defer c1.Close()
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- c1.Run("sess-block", "", "hi", nil, func(string) {}) }()
|
||||
|
||||
// Wait until the slot is in use, then a second dial must be refused.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca, Timeout: 500 * time.Millisecond})
|
||||
if err != nil {
|
||||
return // refused as expected
|
||||
}
|
||||
_ = c2.Close()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("second connection was not refused at the connection cap")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/daemon"
|
||||
)
|
||||
|
||||
const defaultDialTimeout = 15 * time.Second
|
||||
|
||||
// RemoteConfig configures a remote dial.
|
||||
type RemoteConfig struct {
|
||||
// Address is host:port of the remote bridge (required).
|
||||
Address string
|
||||
// Token is the bearer token (required).
|
||||
Token string
|
||||
// CACertFile, when set, is the only CA trusted for the server cert (use for a
|
||||
// self-signed bridge). Empty => system roots. InsecureSkipVerify is never used.
|
||||
CACertFile string
|
||||
// ServerName overrides the TLS/SNI verification name; empty => host of Address.
|
||||
ServerName string
|
||||
// Timeout bounds the dial + auth handshake; 0 => default.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// DialRemote establishes a verified TLS connection to a remote bridge,
|
||||
// authenticates with the bearer token, and returns a daemon.Client ready for
|
||||
// Run/Attach/Status — the same client the local socket uses, so remote and local
|
||||
// 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")
|
||||
}
|
||||
token := strings.TrimSpace(cfg.Token)
|
||||
if token == "" {
|
||||
return nil, errors.New("remote: a non-empty token is required")
|
||||
}
|
||||
tlsConfig, err := clientTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultDialTimeout
|
||||
}
|
||||
// Context-aware TLS dial (tls.DialWithDialer is deprecated and not
|
||||
// cancellation-aware). The context bounds the dial + TLS handshake only; the
|
||||
// returned conn is independent of it for the subsequent auth/stream I/O.
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
dialer := &tls.Dialer{NetDialer: &net.Dialer{}, Config: tlsConfig}
|
||||
conn, err := dialer.DialContext(dialCtx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: dial %s: %w", address, err)
|
||||
}
|
||||
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion, Mode: mode}); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: send auth: %w", err)
|
||||
}
|
||||
resp, err := readAuthResponse(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("remote: auth handshake: %w", err)
|
||||
}
|
||||
if !resp.OK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// clientTLSConfig builds a verifying client TLS config. It never disables
|
||||
// verification; for a self-signed bridge, set CACertFile to the bridge's cert.
|
||||
func clientTLSConfig(cfg RemoteConfig) (*tls.Config, error) {
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
serverName := strings.TrimSpace(cfg.ServerName)
|
||||
if serverName == "" {
|
||||
if host, _, err := net.SplitHostPort(cfg.Address); err == nil {
|
||||
serverName = host
|
||||
}
|
||||
}
|
||||
tc.ServerName = serverName
|
||||
if ca := strings.TrimSpace(cfg.CACertFile); ca != "" {
|
||||
pem, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote: read CA cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, errors.New("remote: CA cert file contained no certificates")
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
return tc, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -167,6 +167,13 @@ func (s *Server) writeStatusFile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeConn runs the control protocol (handshake + one command) on an
|
||||
// already-established connection, reusing the exact local dispatch path. The
|
||||
// remote bridge calls it AFTER authenticating a TLS connection, so a remote
|
||||
// session is handled identically to a local one (same SessionManager/Pool, same
|
||||
// sandbox/risk model) — remote never bypasses the local controls. It closes conn.
|
||||
func (s *Server) ServeConn(conn net.Conn) { s.handleConn(conn) }
|
||||
|
||||
// handleConn performs the handshake then dispatches a single control command.
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+79
-187
@@ -2,9 +2,6 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,20 +11,56 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// ServerAuthOAuth is the value of an MCP server's auth field that selects the
|
||||
// OAuth 2.0 + PKCE authorization-code flow.
|
||||
const ServerAuthOAuth = "oauth"
|
||||
|
||||
const (
|
||||
wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
defaultLoginTimeout = 3 * time.Minute
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier, the minimum
|
||||
// length permitted by the PKCE spec while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
)
|
||||
const defaultLoginTimeout = 3 * time.Minute
|
||||
|
||||
// MCP OAuth delegates its transport/identity-agnostic engine to internal/oauth
|
||||
// (PKCE, RFC 8414 discovery, authorize-URL build, token exchange/refresh) so the
|
||||
// two share one implementation. MCP keeps its own LoginOptions/Login
|
||||
// orchestration, OAuthConfig/StoredToken types, loopback handling, CLI, and
|
||||
// on-disk token format — all behavior-preserving. The shared engine also adds an
|
||||
// https-only token-endpoint guard (loopback exempt), a hardening MCP inherits.
|
||||
|
||||
// pkceToParams converts the shared PKCE pair to MCP's local type.
|
||||
func pkceToParams(p oauth.PKCE) pkceParams {
|
||||
return pkceParams{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// pkceToOAuth converts MCP's local PKCE type back to the shared type.
|
||||
func pkceToOAuth(p pkceParams) oauth.PKCE {
|
||||
return oauth.PKCE{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// configFor builds the shared oauth.Config from MCP's OAuthConfig + resolved
|
||||
// endpoints for a flow.
|
||||
func configFor(cfg OAuthConfig, metadata authServerMetadata) oauth.Config {
|
||||
return oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
AuthorizationEndpoint: metadata.AuthorizationEndpoint,
|
||||
TokenEndpoint: metadata.TokenEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// tokenToStored converts a shared oauth.Token to MCP's StoredToken (MCP does not
|
||||
// use the Account field).
|
||||
func tokenToStored(t oauth.Token) StoredToken {
|
||||
return StoredToken{
|
||||
AccessToken: t.AccessToken,
|
||||
RefreshToken: t.RefreshToken,
|
||||
TokenType: t.TokenType,
|
||||
Scopes: t.Scopes,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
|
||||
// Endpoints may be discovered from the server's metadata document; explicit
|
||||
@@ -84,34 +117,20 @@ type authorizationFlow struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// discoverAuthorizationServer fetches the OAuth 2.0 authorization server
|
||||
// metadata document published at the well-known path under the given base URL.
|
||||
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
|
||||
// at the well-known path under baseURL, via the shared engine.
|
||||
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return authServerMetadata{}, fmt.Errorf("authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata authServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
return authServerMetadata{
|
||||
Issuer: meta.Issuer,
|
||||
AuthorizationEndpoint: meta.AuthorizationEndpoint,
|
||||
TokenEndpoint: meta.TokenEndpoint,
|
||||
RegistrationEndpoint: meta.RegistrationEndpoint,
|
||||
ScopesSupported: meta.ScopesSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveAuthorizationServer discovers metadata and applies explicit config
|
||||
@@ -149,44 +168,23 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge.
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge via the
|
||||
// shared engine.
|
||||
func newPKCE() (pkceParams, error) {
|
||||
verifierRaw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(verifierRaw); err != nil {
|
||||
return pkceParams{}, fmt.Errorf("generate PKCE verifier: %w", err)
|
||||
p, err := oauth.NewPKCE()
|
||||
if err != nil {
|
||||
return pkceParams{}, err
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(verifierRaw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return pkceParams{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
|
||||
return pkceToParams(p), nil
|
||||
}
|
||||
|
||||
func newState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
return oauth.NewState()
|
||||
}
|
||||
|
||||
// authorizationURL builds the authorization request URL.
|
||||
// authorizationURL builds the authorization request URL via the shared engine.
|
||||
func (flow *authorizationFlow) authorizationURL(redirectURI string) (string, error) {
|
||||
parsed, err := url.Parse(flow.metadata.AuthorizationEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", flow.config.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", flow.state)
|
||||
query.Set("code_challenge", flow.pkce.Challenge)
|
||||
query.Set("code_challenge_method", flow.pkce.Method)
|
||||
if len(flow.config.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(flow.config.Scopes, " "))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
return oauth.BuildAuthorizationURL(configFor(flow.config, flow.metadata), pkceToOAuth(flow.pkce), flow.state, redirectURI, nil)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization
|
||||
@@ -209,117 +207,30 @@ func (flow *authorizationFlow) parseCallback(values url.Values) (string, error)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens via the
|
||||
// shared engine.
|
||||
func (flow *authorizationFlow) exchangeCode(ctx context.Context, code string, redirectURI string) (StoredToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", flow.config.ClientID)
|
||||
form.Set("code_verifier", flow.pkce.Verifier)
|
||||
if secret := strings.TrimSpace(flow.config.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
token, err := oauth.ExchangeCode(ctx, flow.httpClient, configFor(flow.config, flow.metadata), code, flow.pkce.Verifier, redirectURI, flow.now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return postTokenRequest(ctx, flow.httpClient, flow.metadata.TokenEndpoint, form, StoredToken{Scopes: flow.config.Scopes}, flow.now)
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token. A
|
||||
// response that omits a new refresh token preserves the previous one.
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token via the
|
||||
// shared engine. A response that omits a new refresh token preserves the
|
||||
// previous one.
|
||||
func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfig, current StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
refresh := strings.TrimSpace(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return StoredToken{}, errors.New("no refresh token available")
|
||||
}
|
||||
tokenEndpoint := strings.TrimSpace(cfg.TokenEndpoint)
|
||||
if tokenEndpoint == "" {
|
||||
return StoredToken{}, errors.New("no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := strings.TrimSpace(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
refreshed, err := postTokenRequest(ctx, client, tokenEndpoint, form, StoredToken{Scopes: current.Scopes, RefreshToken: refresh}, now)
|
||||
token, err := oauth.Refresh(ctx, client, oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
TokenEndpoint: cfg.TokenEndpoint,
|
||||
}, oauth.Token{AccessToken: current.AccessToken, RefreshToken: current.RefreshToken, TokenType: current.TokenType, Scopes: current.Scopes, ExpiresAt: current.ExpiresAt}, now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// tokenResponse is the JSON returned by the token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// postTokenRequest performs a token endpoint POST and maps the response onto a
|
||||
// StoredToken. The base token supplies values to preserve (e.g. an existing
|
||||
// refresh token or scope set) when the response omits them.
|
||||
func postTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return StoredToken{}, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
// A malformed body on an error status is reported as a status error below.
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(parsed.AccessToken) == "" {
|
||||
return StoredToken{}, errors.New("token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if strings.TrimSpace(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if strings.TrimSpace(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := strings.TrimSpace(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// registerClient performs dynamic client registration against the registration
|
||||
@@ -505,22 +416,3 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
// RFC 8414: the well-known segment is inserted between the host and any issuer
|
||||
// path component, so a path-based issuer (https://host/tenant-a) is probed at
|
||||
// https://host/.well-known/oauth-authorization-server/tenant-a — not at the
|
||||
// host root (which would break tenant-scoped discovery).
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
+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,213 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// defaultDeviceCodeLifetime bounds a device authorization that omits expires_in,
|
||||
// so the expiry gate in PollDeviceToken is always active (fail closed).
|
||||
const defaultDeviceCodeLifetime = 10 * time.Minute
|
||||
|
||||
// DeviceAuth is the result of an RFC 8628 device-authorization request.
|
||||
type DeviceAuth struct {
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
VerificationURI string
|
||||
VerificationURIComplete string
|
||||
Interval time.Duration
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type deviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Interval int64 `json:"interval"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode performs the RFC 8628 device-authorization request. This is
|
||||
// the headless/SSH login path (no browser, no loopback).
|
||||
func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error) {
|
||||
endpoint := trimmed(cfg.DeviceAuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: provider has no device authorization endpoint")
|
||||
}
|
||||
if err := validateTokenEndpoint(endpoint); err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
// Confidential clients must authenticate on the device endpoint too,
|
||||
// consistent with the token poll (pollDeviceOnce).
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed deviceAuthResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization error %q", parsed.Error)
|
||||
}
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.DeviceCode) == "" || trimmed(parsed.UserCode) == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: device authorization response missing device_code/user_code")
|
||||
}
|
||||
interval := time.Duration(parsed.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second // RFC 8628 default
|
||||
}
|
||||
auth := DeviceAuth{
|
||||
DeviceCode: parsed.DeviceCode,
|
||||
UserCode: parsed.UserCode,
|
||||
VerificationURI: parsed.VerificationURI,
|
||||
VerificationURIComplete: parsed.VerificationURIComplete,
|
||||
Interval: interval,
|
||||
}
|
||||
lifetime := time.Duration(parsed.ExpiresIn) * time.Second
|
||||
if lifetime <= 0 {
|
||||
// Fail closed: a response without expires_in must still get a bounded
|
||||
// lifetime so the poll loop's expiry gate stays active.
|
||||
lifetime = defaultDeviceCodeLifetime
|
||||
}
|
||||
auth.ExpiresAt = now().Add(lifetime)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint for the device grant until the user
|
||||
// approves, the device code expires, ctx is done, or a terminal error occurs. It
|
||||
// honors authorization_pending (keep waiting), slow_down (increase the
|
||||
// interval), and expired_token.
|
||||
func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
interval := auth.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
for {
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Token{}, fmt.Errorf("oauth: device authorization canceled: %w", ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
// Re-check expiry after sleeping: slow_down (or a long interval) can push
|
||||
// the next poll past ExpiresAt, and we must not poll after the deadline.
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
token, err := pollDeviceOnce(ctx, client, cfg, auth.DeviceCode, now)
|
||||
switch {
|
||||
case err == nil:
|
||||
return token, nil
|
||||
case errors.Is(err, ErrAuthorizationPending):
|
||||
// keep waiting at the current interval
|
||||
case errors.Is(err, ErrSlowDown):
|
||||
interval += 5 * time.Second // RFC 8628: add 5s on slow_down
|
||||
default:
|
||||
return Token{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollDeviceOnce makes a single device-token poll, translating the RFC 8628
|
||||
// error codes into ErrAuthorizationPending / ErrSlowDown / terminal errors.
|
||||
func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, deviceCode string, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(cfg.TokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: device token poll failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && trimmed(parsed.AccessToken) != "" {
|
||||
token := Token{Scopes: cfg.Scopes}
|
||||
token.AccessToken = parsed.AccessToken
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
token.TokenType = parsed.TokenType
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
switch parsed.Error {
|
||||
case "authorization_pending":
|
||||
return Token{}, ErrAuthorizationPending
|
||||
case "slow_down":
|
||||
return Token{}, ErrSlowDown
|
||||
case "expired_token":
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
case "access_denied":
|
||||
return Token{}, errors.New("oauth: authorization denied by the user")
|
||||
case "":
|
||||
return Token{}, fmt.Errorf("oauth: device token poll returned HTTP %d", response.StatusCode)
|
||||
default:
|
||||
return Token{}, fmt.Errorf("oauth: device token error %q", parsed.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
|
||||
var gotSecret string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotSecret = r.Form.Get("client_secret")
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x","expires_in":600,"interval":5}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
// A confidential client must authenticate on the device endpoint too.
|
||||
_, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if gotSecret != "shh" {
|
||||
t.Fatalf("client_secret = %q, want shh", gotSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL, Scopes: []string{"a"}}
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.DeviceCode != "dc" || auth.UserCode != "WXYZ-1234" {
|
||||
t.Fatalf("device auth = %+v", auth)
|
||||
}
|
||||
if auth.Interval != 3*time.Second {
|
||||
t.Fatalf("interval = %v, want 3s", auth.Interval)
|
||||
}
|
||||
if auth.VerificationURIComplete == "" {
|
||||
t.Fatal("missing verification_uri_complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodeDefaultsInterval(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.Interval != 5*time.Second {
|
||||
t.Fatalf("default interval = %v, want 5s", auth.Interval)
|
||||
}
|
||||
// A response without expires_in must still get a bounded expiry (fail closed),
|
||||
// so the poll loop's expiry gate stays active.
|
||||
if auth.ExpiresAt.IsZero() {
|
||||
t.Fatal("missing expires_in must default to a bounded ExpiresAt, got zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenHonorsPendingThenSucceeds(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Small interval + future expiry so the loop is fast.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("polled %d times, want 3 (2 pending + 1 success)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenSlowDown(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"slow_down"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Expiry well beyond the slow_down back-off (+5s) so the success path never
|
||||
// depends on polling after the device code has expired.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(30 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken (slow_down): %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenExpired(t *testing.T) {
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: "https://unused/token"}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: time.Millisecond, ExpiresAt: time.Now().Add(-time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), http.DefaultClient, cfg, auth, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenAccessDenied(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"access_denied"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("err = %v, want access denied", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
|
||||
// ServerMetadata is the subset of an RFC 8414 authorization-server metadata
|
||||
// document the engine consumes.
|
||||
type ServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
// DiscoverAuthorizationServer fetches the RFC 8414 metadata document at the
|
||||
// well-known path under baseURL.
|
||||
func DiscoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (ServerMetadata, error) {
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
return fetchMetadata(ctx, client, metadataURL)
|
||||
}
|
||||
|
||||
// fetchMetadata GETs and decodes an authorization-server metadata document at an
|
||||
// already-resolved URL (no well-known recomputation), so callers can also use it
|
||||
// for the OIDC openid-configuration path.
|
||||
func fetchMetadata(ctx context.Context, client *http.Client, metadataURL string) (ServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata ServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, tokenResponseLimit)).Decode(&metadata); err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// joinWellKnown builds the RFC 8414 metadata URL, inserting the well-known
|
||||
// segment between host and any issuer path component (so a path-based issuer is
|
||||
// probed tenant-scoped, not at the host root).
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(trimmed(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("oauth: invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenResponseLimit = 1 << 20 // 1 MiB cap on token-endpoint bodies
|
||||
|
||||
// tokenResponse is the JSON returned by a token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL constructs the authorization request URL for an
|
||||
// authorization-code + PKCE flow. PKCE S256 is always included; a non-S256
|
||||
// method is refused (ErrPKCEDowngrade). extraParams (and cfg.ExtraAuthParams)
|
||||
// are appended last.
|
||||
func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, extraParams map[string]string) (string, error) {
|
||||
if pkce.Method != MethodS256 {
|
||||
return "", ErrPKCEDowngrade
|
||||
}
|
||||
endpoint := trimmed(cfg.AuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return "", errors.New("oauth: no authorization endpoint configured")
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", cfg.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", pkce.Challenge)
|
||||
query.Set("code_challenge_method", pkce.Method)
|
||||
if len(cfg.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
// Extra params must never override the reserved OAuth/PKCE fields above
|
||||
// (e.g. forcing code_challenge_method=plain or rewriting state/redirect_uri),
|
||||
// which would break the flow's security guarantees.
|
||||
for k, v := range cfg.ExtraAuthParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
for k, v := range extraParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isReservedAuthParam reports whether a query key is one the engine controls and
|
||||
// must not be overridable by caller-supplied extra params.
|
||||
func isReservedAuthParam(key string) bool {
|
||||
switch key {
|
||||
case "response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
|
||||
// not https, unless it is a loopback host (mirrors oauth2-client.js
|
||||
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
parsed, err := url.Parse(trimmed(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
func ExchangeCode(ctx context.Context, client *http.Client, cfg Config, code, verifier, redirectURI string, now func() time.Time) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("code_verifier", verifier)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, Token{Scopes: cfg.Scopes}, now)
|
||||
}
|
||||
|
||||
// Refresh exchanges a refresh token for a fresh access token. A response that
|
||||
// omits a new refresh token preserves the current one.
|
||||
func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token, now func() time.Time) (Token, error) {
|
||||
refresh := trimmed(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return Token{}, ErrNoRefreshToken
|
||||
}
|
||||
if trimmed(cfg.TokenEndpoint) == "" {
|
||||
return Token{}, errors.New("oauth: no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now)
|
||||
}
|
||||
|
||||
// PostToken performs a token-endpoint POST and maps the response onto a Token.
|
||||
// The https guard is applied first. The base token supplies values to preserve
|
||||
// (e.g. an existing refresh token or scopes) when the response omits them.
|
||||
// Error messages carry only the server's error/error_description — never the raw
|
||||
// body — so token material in an unexpected payload is not leaked.
|
||||
func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base Token, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.AccessToken) == "" {
|
||||
return Token{}, errors.New("oauth: token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if trimmed(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if trimmed(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPKCE(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPKCE: %v", err)
|
||||
}
|
||||
if p.Method != MethodS256 {
|
||||
t.Fatalf("method = %q, want S256", p.Method)
|
||||
}
|
||||
if len(p.Verifier) != 43 { // base64url(32 bytes) = 43 chars
|
||||
t.Fatalf("verifier length = %d, want 43", len(p.Verifier))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Fatalf("challenge = %q, want base64url(sha256(verifier))", p.Challenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStateUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
s, err := NewState()
|
||||
if err != nil {
|
||||
t.Fatalf("NewState: %v", err)
|
||||
}
|
||||
if s == "" || seen[s] {
|
||||
t.Fatalf("state not unique/non-empty: %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
Scopes: []string{"read", "write"},
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
ExtraAuthParams: map[string]string{"login_hint": "[email protected]"},
|
||||
}
|
||||
pkce := PKCE{Verifier: "v", Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "state-xyz", "http://127.0.0.1:9/callback", map[string]string{"prompt": "login"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
u, _ := url.Parse(raw)
|
||||
q := u.Query()
|
||||
checks := map[string]string{
|
||||
"response_type": "code",
|
||||
"client_id": "client-123",
|
||||
"redirect_uri": "http://127.0.0.1:9/callback",
|
||||
"state": "state-xyz",
|
||||
"code_challenge": "chal",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "read write",
|
||||
"login_hint": "[email protected]",
|
||||
"prompt": "login",
|
||||
}
|
||||
for k, want := range checks {
|
||||
if got := q.Get(k); got != want {
|
||||
t.Errorf("query %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLIgnoresReservedExtras(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "c",
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
// A hostile config tries to weaken the flow via extra params.
|
||||
ExtraAuthParams: map[string]string{"state": "attacker", "code_challenge_method": "plain", "login_hint": "keep-me"},
|
||||
}
|
||||
pkce := PKCE{Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "real-state", "http://127.0.0.1:9/cb", map[string]string{"redirect_uri": "http://evil/cb"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
parsed, _ := url.Parse(raw)
|
||||
q := parsed.Query()
|
||||
if q.Get("state") != "real-state" {
|
||||
t.Fatalf("state overridden: %q", q.Get("state"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("PKCE method downgraded: %q", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://127.0.0.1:9/cb" {
|
||||
t.Fatalf("redirect_uri overridden: %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("login_hint") != "keep-me" {
|
||||
t.Fatalf("non-reserved extra param should still apply: %q", q.Get("login_hint"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
|
||||
_, err := BuildAuthorizationURL(Config{AuthorizationEndpoint: "https://a/x"}, PKCE{Method: "plain"}, "s", "r", nil)
|
||||
if !errors.Is(err, ErrPKCEDowngrade) {
|
||||
t.Fatalf("err = %v, want ErrPKCEDowngrade", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
"http://127.0.0.1:1455/token": true, // loopback exempt
|
||||
"http://localhost:1455/token": true,
|
||||
"http://[::1]:1455/token": true,
|
||||
"http://auth.example.com/token": false, // non-https, non-loopback
|
||||
"ftp://auth.example.com/token": false,
|
||||
}
|
||||
for endpoint, ok := range cases {
|
||||
err := validateTokenEndpoint(endpoint)
|
||||
if ok && err != nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = %v, want nil", endpoint, err)
|
||||
}
|
||||
if !ok && err == nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = nil, want error", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("grant_type") != "authorization_code" || r.FormValue("code_verifier") != "verifier" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600,"scope":"read write"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "verifier", "http://127.0.0.1/cb", func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.TokenType != "Bearer" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if !tok.ExpiresAt.Equal(now.Add(time.Hour)) {
|
||||
t.Fatalf("expiresAt = %v, want now+1h", tok.ExpiresAt)
|
||||
}
|
||||
if strings.Join(tok.Scopes, " ") != "read write" {
|
||||
t.Fatalf("scopes = %v", tok.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeErrorRedactsBody(t *testing.T) {
|
||||
// The error body contains a stray access_token-looking field; it must NOT
|
||||
// surface in the returned error (only error/error_description do).
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"bad code","access_token":"SECRET-LEAK"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
_, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "v", "http://127.0.0.1/cb", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_grant") || !strings.Contains(err.Error(), "bad code") {
|
||||
t.Fatalf("error should carry error/description, got %q", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "SECRET-LEAK") {
|
||||
t.Fatalf("error leaked raw body token material: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostTokenRefusesInsecureEndpoint(t *testing.T) {
|
||||
_, err := PostToken(context.Background(), http.DefaultClient, "http://auth.example.com/token", url.Values{}, Token{}, nil)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNoToken(t *testing.T) {
|
||||
_, err := Refresh(context.Background(), http.DefaultClient, Config{TokenEndpoint: "https://a/token"}, Token{}, nil)
|
||||
if !errors.Is(err, ErrNoRefreshToken) {
|
||||
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreservesRefreshTokenWhenOmitted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no refresh_token in response
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "new-at" || tok.RefreshToken != "keep-me" {
|
||||
t.Fatalf("refresh should preserve old refresh token: %+v", tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fileLockTimeout = 5 * time.Second
|
||||
fileLockStaleAfter = 30 * time.Second
|
||||
)
|
||||
|
||||
var lockSeq atomic.Uint64
|
||||
|
||||
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
|
||||
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
|
||||
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
|
||||
// store). Release is ownership-aware: it removes the lock only if it still holds
|
||||
// our token, so a stale-broken holder cannot delete a newer holder's lock.
|
||||
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
|
||||
deadline := now().Add(fileLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A partial/failed write would leave a lock file without our token, so
|
||||
// the releaser below could never delete it — stranding the lock for
|
||||
// other processes. Fail closed: remove the file and surface the error.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
|
||||
}
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
|
||||
stale, _ := os.ReadFile(lockPath)
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if now().After(deadline) {
|
||||
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
// redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port,
|
||||
// verify the CSRF state, hand back the code, then close.
|
||||
type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and
|
||||
// begins serving. state is the CSRF value the callback must echo back. Call
|
||||
// RedirectURI to build the redirect_uri, then Wait for the code. Always Close.
|
||||
func NewLoopbackListener(state string) (*LoopbackListener, error) {
|
||||
// Refuse an empty state: the callback check compares the query's state to this
|
||||
// value, so an empty state would match a callback carrying no state at all,
|
||||
// defeating CSRF protection (fail closed).
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: start loopback redirect listener: %w", err)
|
||||
}
|
||||
l := &LoopbackListener{
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
}
|
||||
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
|
||||
go func() { _ = l.server.Serve(ln) }()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.
|
||||
func (l *LoopbackListener) RedirectURI() string {
|
||||
return fmt.Sprintf("http://%s/callback", l.listener.Addr().String())
|
||||
}
|
||||
|
||||
func (l *LoopbackListener) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, err := parseCallback(r.URL.Query(), l.state)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case l.result <- callbackResult{code: code, err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the callback arrives or ctx is done, returning the
|
||||
// authorization code.
|
||||
func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
select {
|
||||
case res := <-l.result:
|
||||
return res.code, res.err
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("oauth: timed out waiting for authorization callback: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts the listener down (bounded), idempotent.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = l.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
// rejecting a mismatched state (CSRF) and surfacing provider errors.
|
||||
func parseCallback(values url.Values, wantState string) (string, error) {
|
||||
if got := values.Get("state"); got != wantState {
|
||||
return "", ErrStateMismatch
|
||||
}
|
||||
if providerErr := strings.TrimSpace(values.Get("error")); providerErr != "" {
|
||||
if desc := strings.TrimSpace(values.Get("error_description")); desc != "" {
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q: %s", providerErr, desc)
|
||||
}
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q", providerErr)
|
||||
}
|
||||
code := strings.TrimSpace(values.Get("code"))
|
||||
if code == "" {
|
||||
return "", errors.New("oauth: callback missing authorization code")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoopbackCapturesCode(t *testing.T) {
|
||||
l, err := NewLoopbackListener("the-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if !strings.HasPrefix(l.RedirectURI(), "http://127.0.0.1:") {
|
||||
t.Fatalf("redirect URI not loopback: %q", l.RedirectURI())
|
||||
}
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=auth-code&state=the-state")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
code, err := l.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if code != "auth-code" {
|
||||
t.Fatalf("code = %q, want auth-code", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoopbackListenerRejectsEmptyState(t *testing.T) {
|
||||
if _, err := NewLoopbackListener(" "); err == nil {
|
||||
t.Fatal("an empty CSRF state must be rejected (fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackRejectsStateMismatch(t *testing.T) {
|
||||
l, err := NewLoopbackListener("expected-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=x&state=WRONG")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if !errors.Is(err, ErrStateMismatch) {
|
||||
t.Fatalf("Wait err = %v, want ErrStateMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackTimesOut(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := l.Wait(ctx); err == nil {
|
||||
t.Fatal("Wait should time out cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackProviderError(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?error=access_denied&error_description=nope&state=s")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "access_denied") {
|
||||
t.Fatalf("Wait err = %v, want provider error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"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
|
||||
|
||||
const oidcWellKnownPath = "/.well-known/openid-configuration"
|
||||
|
||||
// Manager ties the token store, provider registry, and HTTP client together to
|
||||
// run logins and serve fresh access tokens. It is the high-level entrypoint the
|
||||
// CLI and request paths use.
|
||||
type Manager struct {
|
||||
store *Store
|
||||
registry *Registry
|
||||
client *http.Client
|
||||
env map[string]string
|
||||
now func() time.Time
|
||||
buffer time.Duration
|
||||
out io.Writer
|
||||
// openBrowser is invoked with the authorization URL for loopback logins.
|
||||
// Tests inject a function that drives the loopback redirect.
|
||||
openBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
OpenBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// NewManager builds a Manager, filling defaults.
|
||||
func NewManager(opts ManagerOptions) (*Manager, error) {
|
||||
if opts.Store == nil {
|
||||
return nil, fmt.Errorf("oauth: manager requires a store")
|
||||
}
|
||||
registry := opts.Registry
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
buffer := opts.RefreshBuffer
|
||||
if buffer <= 0 {
|
||||
buffer = defaultRefreshBuffer
|
||||
}
|
||||
out := opts.Out
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
open := opts.OpenBrowser
|
||||
if open == nil {
|
||||
open = func(string) error { return nil }
|
||||
}
|
||||
return &Manager{
|
||||
store: opts.Store, registry: registry, client: client,
|
||||
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoginOptions configures a single provider login.
|
||||
type LoginOptions struct {
|
||||
Provider string
|
||||
Device bool // force device-code flow
|
||||
ExtraScopes []string // appended to the provider's scopes
|
||||
Timeout time.Duration // bounds the whole interactive login
|
||||
}
|
||||
|
||||
// Login runs the provider login (loopback by default, device-code when
|
||||
// requested or when the provider only supports device), stores the token under
|
||||
// "provider:<name>", and returns a redaction-safe status.
|
||||
func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error) {
|
||||
cfg, flow, err := m.registry.ResolveConfig(opts.Provider, m.env)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if len(opts.ExtraScopes) > 0 {
|
||||
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
loginCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, err = m.resolveEndpoints(loginCtx, cfg)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
useDevice := opts.Device || flow == FlowDevice
|
||||
var token Token
|
||||
if useDevice {
|
||||
token, err = m.loginDevice(loginCtx, cfg)
|
||||
} else {
|
||||
token, err = m.loginLoopback(loginCtx, cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
key := ProviderKey(opts.Provider)
|
||||
if err := m.store.Save(key, token); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
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) {
|
||||
if trimmed(cfg.IssuerURL) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if cfg.AuthorizationEndpoint != "" && cfg.TokenEndpoint != "" && cfg.DeviceAuthorizationEndpoint != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
// Discovery is best-effort: a failure is non-fatal because pinned endpoints
|
||||
// may already be sufficient for the chosen flow. Only merge on success (and
|
||||
// never return from the error branch, which would trip the nilerr linter).
|
||||
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
|
||||
if cfg.AuthorizationEndpoint == "" {
|
||||
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
|
||||
}
|
||||
if cfg.TokenEndpoint == "" {
|
||||
cfg.TokenEndpoint = meta.TokenEndpoint
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint == "" {
|
||||
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// discover tries the OAuth (RFC 8414) well-known path, then the OIDC
|
||||
// openid-configuration path (some issuers publish only the latter).
|
||||
func (m *Manager) discover(ctx context.Context, issuer string) (ServerMetadata, error) {
|
||||
meta, err := DiscoverAuthorizationServer(ctx, m.client, issuer)
|
||||
if err == nil && (meta.AuthorizationEndpoint != "" || meta.TokenEndpoint != "") {
|
||||
return meta, nil
|
||||
}
|
||||
oidcURL := strings.TrimRight(strings.TrimSpace(issuer), "/") + oidcWellKnownPath
|
||||
return fetchMetadata(ctx, m.client, oidcURL)
|
||||
}
|
||||
|
||||
func (m *Manager) loginLoopback(ctx context.Context, cfg Config) (Token, error) {
|
||||
if trimmed(cfg.AuthorizationEndpoint) == "" {
|
||||
return Token{}, fmt.Errorf("oauth: no authorization endpoint (set the authorize URL or a discoverable issuer)")
|
||||
}
|
||||
state, err := NewState()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
pkce, err := NewPKCE()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
listener, err := NewLoopbackListener(state)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer listener.Close()
|
||||
redirectURI := listener.RedirectURI()
|
||||
authURL, err := BuildAuthorizationURL(cfg, pkce, state, redirectURI, nil)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
fmt.Fprintf(m.out, "Open this URL to authorize:\n %s\n", authURL)
|
||||
if err := m.openBrowser(authURL); err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: open authorization URL: %w", err)
|
||||
}
|
||||
code, err := listener.Wait(ctx)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
return ExchangeCode(ctx, m.client, cfg, code, pkce.Verifier, redirectURI, m.now)
|
||||
}
|
||||
|
||||
func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
|
||||
auth, err := RequestDeviceCode(ctx, m.client, cfg, m.now)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
target := auth.VerificationURIComplete
|
||||
if target == "" {
|
||||
target = auth.VerificationURI
|
||||
}
|
||||
// user_code is meant to be displayed to the user; it is not a secret.
|
||||
fmt.Fprintf(m.out, "To authorize, visit:\n %s\nand enter code: %s\n", target, auth.UserCode)
|
||||
return PollDeviceToken(ctx, m.client, cfg, auth, m.now)
|
||||
}
|
||||
|
||||
// GetFresh returns a valid access token for key, refreshing on-demand if the
|
||||
// stored token is expired or within the refresh buffer. Mirrors
|
||||
// checkAndRefreshOAuthTokenIfNeeded.
|
||||
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A still-valid token is returned without resolving the provider config, so a
|
||||
// readable login never depends on config that is only needed to refresh (e.g.
|
||||
// an opt-in preset client_id). Only an actual refresh resolves endpoints.
|
||||
if !token.NeedsRefresh(m.now(), m.buffer) {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
cfg, err := m.resolveConfigForKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
// Handle401 forces a refresh after an upstream 401, returning the new access
|
||||
// token. Mirrors handleOAuth401Error.
|
||||
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := m.resolveConfigForKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, current Token) (string, error) {
|
||||
refreshed, err := Refresh(ctx, m.client, cfg, current, m.now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := m.store.Save(key, refreshed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
|
||||
// loadToken loads a stored token for key without resolving any provider config,
|
||||
// so reading a still-valid login never depends on refresh-only configuration.
|
||||
func (m *Manager) loadToken(key string) (Token, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token, ok, err := m.store.Load(key)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if !ok {
|
||||
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 Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
|
||||
}
|
||||
cfg, _, err := m.registry.ResolveConfig(name, m.env)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// 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.
|
||||
func (m *Manager) Logout(name string) (bool, error) {
|
||||
return m.store.Delete(ProviderKey(name))
|
||||
}
|
||||
|
||||
// StatusAll returns the status of every provider login.
|
||||
func (m *Manager) StatusAll() ([]Status, error) {
|
||||
return m.store.Status(KeyPrefixProvider)
|
||||
}
|
||||
|
||||
func (m *Manager) statusFor(key string) (Status, error) {
|
||||
statuses, err := m.store.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
for _, st := range statuses {
|
||||
if st.Key == key {
|
||||
return st, nil
|
||||
}
|
||||
}
|
||||
return Status{Key: key}, nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProvider is an httptest server that plays a token + device endpoint.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T, tokenJSON string) *fakeProvider {
|
||||
t.Helper()
|
||||
fp := &fakeProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fp.tokenHits.Add(1)
|
||||
_, _ = io.WriteString(w, tokenJSON)
|
||||
})
|
||||
mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Approve immediately on poll; short interval so the test is fast.
|
||||
_, _ = io.WriteString(w, `{"device_code":"dc","user_code":"U-1","verification_uri":"https://example/dev","expires_in":600,"interval":1}`)
|
||||
})
|
||||
fp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(fp.server.Close)
|
||||
return fp
|
||||
}
|
||||
|
||||
func managerFor(t *testing.T, env map[string]string, openBrowser func(string) error) *Manager {
|
||||
t.Helper()
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: env,
|
||||
OpenBrowser: openBrowser,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestManagerLoginLoopback(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
// The fake browser drives the loopback redirect with the captured state.
|
||||
openBrowser := func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect := u.Query().Get("redirect_uri")
|
||||
state := u.Query().Get("state")
|
||||
_, err = http.Get(redirect + "?code=the-code&state=" + url.QueryEscape(state))
|
||||
return err
|
||||
}
|
||||
m := managerFor(t, env, openBrowser)
|
||||
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if !status.HasToken || status.Key != ProviderKey("demo") {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
// The token is persisted and GetFresh returns it without a needless refresh.
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "at" {
|
||||
t.Fatalf("access = %q, want at", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoginDevice(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"dev-at","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMODEV_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMODEV_TOKEN_URL": fp.server.URL + "/token",
|
||||
"ZERO_OAUTH_DEMODEV_DEVICE_URL": fp.server.URL + "/device",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demodev", Device: true})
|
||||
if err != nil {
|
||||
t.Fatalf("device Login: %v", err)
|
||||
}
|
||||
if !status.HasToken {
|
||||
t.Fatalf("device status = %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Seed an expired token with a refresh token.
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "fresh-at" {
|
||||
t.Fatalf("access = %q, want fresh-at (refreshed)", access)
|
||||
}
|
||||
if fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("token endpoint hit %d times, want 1 refresh", fp.tokenHits.Load())
|
||||
}
|
||||
// The refreshed token is persisted.
|
||||
stored, _, _ := m.store.Load(ProviderKey("demo"))
|
||||
if stored.AccessToken != "fresh-at" {
|
||||
t.Fatalf("stored token not updated: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshSkipsValidToken(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"should-not-be-used"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "valid" || fp.tokenHits.Load() != 0 {
|
||||
t.Fatalf("a valid token must not be refreshed (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerHandle401ForcesRefresh(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"after-401"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Token is still valid by clock, but Handle401 forces a refresh anyway.
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.Handle401(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Handle401: %v", err)
|
||||
}
|
||||
if access != "after-401" || fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("Handle401 must force a refresh (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLogout(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "a"})
|
||||
removed, err := m.Logout("demo")
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Logout = %v %v", removed, err)
|
||||
}
|
||||
if removed2, _ := m.Logout("demo"); removed2 {
|
||||
t.Fatal("second logout should report nothing removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the
|
||||
// authorization-code + PKCE flow that internal/mcp already uses for MCP servers
|
||||
// into a transport/identity-agnostic core, and adds the pieces provider login
|
||||
// needs: a device-authorization (RFC 8628) grant, a provider registry, a
|
||||
// namespaced + refreshing token store, and a proactive refresh scheduler.
|
||||
//
|
||||
// Security invariants (enforced throughout, never relaxed):
|
||||
// - PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
|
||||
// - A per-flow CSRF state is generated and verified on the callback.
|
||||
// - The loopback callback server binds 127.0.0.1 only, on an OS-assigned port,
|
||||
// serves a single request, then closes.
|
||||
// - The token endpoint must be https (loopback exempt) before any credential is
|
||||
// sent — see validateTokenEndpoint.
|
||||
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
|
||||
// - Token files are 0600, base-confined, and file-locked.
|
||||
//
|
||||
// This package holds NO vendor secrets: provider client IDs and endpoints come
|
||||
// from config/env by default, so no third-party OAuth client identity is used
|
||||
// unless the operator opts in. A small set of built-in presets (e.g. xAI's public
|
||||
// client) exists for convenience but is OFF by default and only consulted when
|
||||
// ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token holds the credentials issued by an authorization server. The token
|
||||
// fields are sensitive: callers must never log them, and the store persists them
|
||||
// 0600. It mirrors the on-disk shape internal/mcp uses so the two stay
|
||||
// format-compatible.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
// Account is an optional non-secret identifier (email / account id) shown in
|
||||
// status output; never a credential.
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token has an expiry that is at or before now.
|
||||
// A zero ExpiresAt means "no known expiry" and is treated as not expired.
|
||||
func (t Token) Expired(now time.Time) bool {
|
||||
return !t.ExpiresAt.IsZero() && !t.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the token is expired or falls within buffer of
|
||||
// expiry (so a proactive/on-demand refresh should run). A token with no expiry
|
||||
// never needs a refresh on a timer.
|
||||
func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool {
|
||||
if t.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !t.ExpiresAt.After(now.Add(buffer))
|
||||
}
|
||||
|
||||
// Config describes how to talk to one authorization server for a flow.
|
||||
// Endpoints may be discovered (RFC 8414) and overridden here.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
|
||||
AuthorizationEndpoint string
|
||||
TokenEndpoint string
|
||||
DeviceAuthorizationEndpoint string
|
||||
RegistrationEndpoint string
|
||||
// IssuerURL is the base for metadata discovery when endpoints are not set.
|
||||
IssuerURL string
|
||||
|
||||
// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
|
||||
ExtraAuthParams map[string]string
|
||||
}
|
||||
|
||||
// Errors returned by the engine. Callers can match these with errors.Is.
|
||||
var (
|
||||
// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
|
||||
ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
|
||||
// ErrStateMismatch is returned when the callback state does not match (CSRF).
|
||||
ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
|
||||
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
||||
// non-https, non-loopback endpoint.
|
||||
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
||||
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
||||
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
||||
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
||||
ErrAuthorizationPending = errors.New("oauth: authorization pending")
|
||||
// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
|
||||
ErrSlowDown = errors.New("oauth: slow down")
|
||||
)
|
||||
|
||||
// trimmed is a tiny helper used across the package.
|
||||
func trimmed(s string) string { return strings.TrimSpace(s) }
|
||||
@@ -0,0 +1,49 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier — the minimum
|
||||
// length the PKCE spec permits while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
// MethodS256 is the only code-challenge method this engine accepts.
|
||||
MethodS256 = "S256"
|
||||
)
|
||||
|
||||
// PKCE is a verifier/challenge pair for an authorization-code flow. The method
|
||||
// is always S256 (the engine refuses "plain").
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
Method string
|
||||
}
|
||||
|
||||
// NewPKCE generates a high-entropy code verifier and its S256 challenge. It
|
||||
// matches internal/mcp's newPKCE byte-for-byte (RawURLEncoding of 32 random
|
||||
// bytes; challenge = base64url(SHA-256(verifier))) so the two engines are
|
||||
// interchangeable.
|
||||
func NewPKCE() (PKCE, error) {
|
||||
raw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return PKCE{}, fmt.Errorf("oauth: generate PKCE verifier: %w", err)
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return PKCE{Verifier: verifier, Challenge: challenge, Method: MethodS256}, nil
|
||||
}
|
||||
|
||||
// NewState generates a high-entropy CSRF state value.
|
||||
func NewState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("oauth: generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Flow selects how a provider delivers the authorization result.
|
||||
type Flow string
|
||||
|
||||
const (
|
||||
// FlowLoopback uses a 127.0.0.1 callback server (browser required).
|
||||
FlowLoopback Flow = "loopback"
|
||||
// FlowDevice uses the RFC 8628 device-code flow (headless/SSH).
|
||||
FlowDevice Flow = "device"
|
||||
)
|
||||
|
||||
// providerNamePattern bounds a provider name to a safe identifier that is also a
|
||||
// valid store-key segment.
|
||||
var providerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ValidateProviderName reports whether name is a safe provider identifier.
|
||||
func ValidateProviderName(name string) error {
|
||||
if !providerNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("oauth: invalid provider name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registry resolves a provider's Config from env/config. By default every provider
|
||||
// is defined entirely by the operator via ZERO_OAUTH_<NAME>_* variables, so no
|
||||
// third-party OAuth client identity is used. A small set of built-in presets
|
||||
// exists for convenience but stays inert unless the operator opts in with
|
||||
// ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed); env always overrides a preset.
|
||||
type Registry struct{}
|
||||
|
||||
// NewRegistry returns the (stateless) env-driven registry.
|
||||
func NewRegistry() *Registry { return &Registry{} }
|
||||
|
||||
// envKey builds the ZERO_OAUTH_<NAME>_<suffix> variable name for a provider.
|
||||
func envKey(name, suffix string) string {
|
||||
up := strings.ToUpper(name)
|
||||
up = strings.NewReplacer("-", "_", ".", "_").Replace(up)
|
||||
return "ZERO_OAUTH_" + up + "_" + suffix
|
||||
}
|
||||
|
||||
// ResolveConfig builds the oauth.Config and default Flow for a provider from its
|
||||
// env/config:
|
||||
//
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
// ZERO_OAUTH_<NAME>_AUTHORIZE_URL (loopback flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_TOKEN_URL (or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_DEVICE_URL (device flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_ISSUER_URL (RFC 8414 / OIDC discovery base)
|
||||
// ZERO_OAUTH_<NAME>_SCOPES (space-separated)
|
||||
// ZERO_OAUTH_<NAME>_FLOW ("loopback" [default] | "device")
|
||||
//
|
||||
// Pinned credential-bearing endpoints must be https (loopback exempt).
|
||||
func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Flow, error) {
|
||||
if err := ValidateProviderName(name); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
// A baked-in preset (if any) supplies defaults, but ONLY when the operator opts
|
||||
// in with ZERO_OAUTH_ALLOW_PRESETS — otherwise no third-party client identity is
|
||||
// used and every field must come from a ZERO_OAUTH_<NAME>_* env var (env wins).
|
||||
var preset providerPreset
|
||||
if presetsAllowed(env) {
|
||||
preset, _ = lookupOAuthPreset(name)
|
||||
}
|
||||
cfg := Config{
|
||||
ClientID: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))), preset.ClientID),
|
||||
ClientSecret: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))), preset.ClientSecret),
|
||||
AuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))), preset.AuthorizationEndpoint),
|
||||
TokenEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))), preset.TokenEndpoint),
|
||||
DeviceAuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))), preset.DeviceAuthorizationEndpoint),
|
||||
IssuerURL: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))), preset.IssuerURL),
|
||||
Scopes: scopesOrPreset(envValue(env, envKey(name, "SCOPES")), preset.Scopes),
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
hint := ""
|
||||
if _, ok := lookupOAuthPreset(name); ok {
|
||||
hint = " (or set ZERO_OAUTH_ALLOW_PRESETS=1 to use the built-in preset)"
|
||||
}
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)%s", name, envKey(name, "CLIENT_ID"), hint)
|
||||
}
|
||||
var flow Flow
|
||||
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
|
||||
case "":
|
||||
flow = preset.Flow
|
||||
if flow == "" {
|
||||
flow = FlowLoopback
|
||||
}
|
||||
case string(FlowLoopback):
|
||||
flow = FlowLoopback
|
||||
case string(FlowDevice):
|
||||
flow = FlowDevice
|
||||
default:
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q has invalid %s (want loopback or device)", name, envKey(name, "FLOW"))
|
||||
}
|
||||
// A token endpoint (for exchange/refresh) must be reachable directly or via
|
||||
// discovery. The per-flow authorize/device endpoints are checked at login
|
||||
// time (after discovery), so a refresh-only config needs only a token URL.
|
||||
if cfg.IssuerURL == "" && cfg.TokenEndpoint == "" {
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q needs %s or %s", name, envKey(name, "TOKEN_URL"), envKey(name, "ISSUER_URL"))
|
||||
}
|
||||
for _, ep := range []string{cfg.TokenEndpoint, cfg.AuthorizationEndpoint, cfg.DeviceAuthorizationEndpoint, cfg.IssuerURL} {
|
||||
if ep == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateTokenEndpoint(ep); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
}
|
||||
return cfg, flow, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveConfigFromEnv(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "my-client",
|
||||
"ZERO_OAUTH_DEMO_CLIENT_SECRET": "shh",
|
||||
"ZERO_OAUTH_DEMO_SCOPES": "read write",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
}
|
||||
cfg, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "my-client" || cfg.ClientSecret != "shh" {
|
||||
t.Fatalf("client creds not applied: %+v", cfg)
|
||||
}
|
||||
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "read" {
|
||||
t.Fatalf("scopes = %v", cfg.Scopes)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://auth.example.com/token" {
|
||||
t.Fatalf("token endpoint = %q", cfg.TokenEndpoint)
|
||||
}
|
||||
if flow != FlowLoopback {
|
||||
t.Fatalf("flow = %q, want loopback default", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigDeviceFlow(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
"ZERO_OAUTH_DEMO_DEVICE_URL": "https://auth.example.com/device",
|
||||
"ZERO_OAUTH_DEMO_FLOW": "device",
|
||||
}
|
||||
_, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if flow != FlowDevice {
|
||||
t.Fatalf("flow = %q, want device", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresClientID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, _, err := r.ResolveConfig("demo", map[string]string{}); err == nil {
|
||||
t.Fatal("missing client id must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresEndpointsOrIssuer(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// client id but no token endpoint and no issuer => error.
|
||||
_, _, err := r.ResolveConfig("demo", map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"})
|
||||
if err == nil {
|
||||
t.Fatal("missing endpoints/issuer must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRejectsInsecureEndpoint(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "http://insecure.example/token", // non-https, non-loopback
|
||||
}
|
||||
_, _, err := r.ResolveConfig("demo", env)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigInvalidName(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, bad := range []string{"", "has space", "../escape", "a/b"} {
|
||||
if _, _, err := r.ResolveConfig(bad, nil); err == nil {
|
||||
t.Errorf("ResolveConfig(%q) should reject invalid name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvKey(t *testing.T) {
|
||||
if got := envKey("my-svc", "CLIENT_ID"); got != "ZERO_OAUTH_MY_SVC_CLIENT_ID" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
if got := envKey("two.part", "SCOPES"); got != "ZERO_OAUTH_TWO_PART_SCOPES" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxSchedulerLoadErrors bounds consecutive token-load failures before the
|
||||
// scheduler gives up, so a transient error retries but a permanent one (e.g. a
|
||||
// deleted token) does not loop forever.
|
||||
const maxSchedulerLoadErrors = 5
|
||||
|
||||
// RefreshScheduler proactively refreshes a stored provider token shortly before
|
||||
// it expires. It is an OPTIMIZATION over the on-demand GetFresh path, never the
|
||||
// source of truth: a failed scheduled refresh is non-fatal and simply retried at
|
||||
// the next expiry. It is a no-op for a token with no refresh token or no expiry.
|
||||
type RefreshScheduler struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewRefreshScheduler returns an idle scheduler.
|
||||
func NewRefreshScheduler() *RefreshScheduler {
|
||||
return &RefreshScheduler{}
|
||||
}
|
||||
|
||||
// Start begins proactively refreshing key via the manager until the context is
|
||||
// canceled or Stop is called. It returns immediately; refresh happens in a
|
||||
// goroutine. Calling Start twice is a no-op after the first.
|
||||
func (s *RefreshScheduler) Start(ctx context.Context, m *Manager, key string) {
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.done = make(chan struct{})
|
||||
s.started = true
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.loop(runCtx, m, key, done)
|
||||
}
|
||||
|
||||
func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, done chan struct{}) {
|
||||
defer close(done)
|
||||
loadErrors := 0
|
||||
for {
|
||||
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
|
||||
// refresh. Give up only after repeated consecutive failures.
|
||||
loadErrors++
|
||||
if loadErrors >= maxSchedulerLoadErrors {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
continue
|
||||
}
|
||||
loadErrors = 0
|
||||
// No refresh token or no expiry => nothing to schedule on a timer.
|
||||
if token.RefreshToken == "" || token.ExpiresAt.IsZero() {
|
||||
return
|
||||
}
|
||||
delay := s.delayUntilRefresh(token.ExpiresAt, m.buffer, m.now())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
// Best-effort refresh; failure is non-fatal — reload and reschedule (the
|
||||
// on-demand GetFresh remains the safety net for callers).
|
||||
if _, err := m.GetFresh(ctx, key); err != nil {
|
||||
// Wait a bounded backoff before retrying so a persistently failing
|
||||
// refresh does not hot-loop.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayUntilRefresh computes how long to wait before refreshing: aim for buffer
|
||||
// before expiry, clamped to >= 0, plus a small deterministic jitter so many
|
||||
// sessions do not refresh in lockstep.
|
||||
func (s *RefreshScheduler) delayUntilRefresh(expiresAt time.Time, buffer time.Duration, now time.Time) time.Duration {
|
||||
target := expiresAt.Add(-buffer)
|
||||
delay := target.Sub(now)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
// Jitter up to ~10% of the buffer, derived from the expiry nanos (no RNG dep).
|
||||
if buffer > 0 {
|
||||
jitter := time.Duration(expiresAt.UnixNano()%int64(buffer/10+1)) % (buffer/10 + 1)
|
||||
delay += jitter
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// Stop cancels the scheduler and waits for its goroutine to exit. Safe to call
|
||||
// more than once and on a never-started scheduler.
|
||||
func (s *RefreshScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshSchedulerNoRefreshTokenIsNoop(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
// Token without a refresh token => scheduler must exit promptly (no-op).
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
// Stop waits for the goroutine; if it didn't exit on its own this still returns
|
||||
// because Stop cancels — but the loop should already be done.
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestRefreshSchedulerRefreshesBeforeExpiry(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"refreshed","expires_in":3600}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": server.URL,
|
||||
},
|
||||
RefreshBuffer: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
// Expiry just past the buffer so the scheduled delay is ~0 and a refresh fires
|
||||
// almost immediately.
|
||||
if err := store.Save(ProviderKey("demo"), Token{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(15 * time.Millisecond)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
defer s.Stop()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
stored, _, err := store.Load(ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if stored.AccessToken == "refreshed" {
|
||||
return // success
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("scheduler did not refresh before expiry (token endpoint hits=%d)", hits.Load())
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
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 (
|
||||
storeSchemaVersion = 1
|
||||
// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
|
||||
// under KeyPrefixMCP in the same format (so a future MCP migration is a key
|
||||
// rename, not a format change).
|
||||
KeyPrefixProvider = "provider:"
|
||||
KeyPrefixMCP = "mcp:"
|
||||
)
|
||||
|
||||
// keyPattern bounds a token key to a safe, single-segment namespaced identifier
|
||||
// so a key can never traverse or collide with store internals.
|
||||
var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ValidateKey reports whether key is a well-formed namespaced token key.
|
||||
func ValidateKey(key string) error {
|
||||
if !keyPattern.MatchString(key) {
|
||||
return fmt.Errorf("oauth: invalid token key %q (want \"provider:<name>\" or \"mcp:<name>\")", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
||||
|
||||
// Status is a redaction-safe summary of a stored token (no secret material).
|
||||
type Status struct {
|
||||
Key string `json:"key"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
HasRefreshToken bool `json:"hasRefreshToken"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// StoreOptions configures where provider OAuth tokens are persisted.
|
||||
type StoreOptions struct {
|
||||
FilePath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
|
||||
// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
|
||||
// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
|
||||
Storage string
|
||||
// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
|
||||
// rest). Ignored when Storage is set.
|
||||
Encrypted bool
|
||||
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
|
||||
// Injected by tests to avoid touching a real keychain.
|
||||
Keyring KeyringClient
|
||||
}
|
||||
|
||||
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
|
||||
// satisfies it; tests inject a fake.
|
||||
type KeyringClient interface {
|
||||
Get(service, account string) (string, bool, error)
|
||||
Set(service, account, secret string) error
|
||||
Delete(service, account string) (bool, error)
|
||||
}
|
||||
|
||||
// Keyring storage stores the whole token blob under one fixed entry.
|
||||
const (
|
||||
keyringService = "zero"
|
||||
keyringAccount = "oauth-tokens"
|
||||
)
|
||||
|
||||
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
|
||||
// written atomically through a pluggable backend (a 0600 file guarded by a
|
||||
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
|
||||
// is AES-256-GCM ciphertext at rest.
|
||||
type Store struct {
|
||||
blob blobStore
|
||||
crypter *aesGCMCrypter // nil => plaintext blob
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type storeFile struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Tokens map[string]Token `json:"tokens"`
|
||||
}
|
||||
|
||||
// ResolveStorePath determines the on-disk location for provider OAuth tokens,
|
||||
// honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.
|
||||
func ResolveStorePath(env map[string]string) (string, error) {
|
||||
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_TOKENS_PATH")); override != "" {
|
||||
if filepath.IsAbs(override) {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
return filepath.Abs(override)
|
||||
}
|
||||
configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME"))
|
||||
if configHome == "" {
|
||||
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
|
||||
if home == "" {
|
||||
var err error
|
||||
home, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: resolve user home: %w", err)
|
||||
}
|
||||
}
|
||||
configHome = filepath.Join(home, ".config")
|
||||
} else if !filepath.IsAbs(configHome) {
|
||||
resolved, err := filepath.Abs(configHome)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configHome = resolved
|
||||
}
|
||||
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewStore builds a token store with the configured backend (file by default,
|
||||
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
|
||||
func NewStore(options StoreOptions) (*Store, error) {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
storage := strings.TrimSpace(options.Storage)
|
||||
if storage == "" {
|
||||
storage = strings.TrimSpace(envValue(options.Env, "ZERO_OAUTH_STORAGE"))
|
||||
}
|
||||
if storage == "" && options.Encrypted {
|
||||
storage = "encrypted-file" // legacy alias
|
||||
}
|
||||
switch storage {
|
||||
case "", "file":
|
||||
path, err := resolveStoreFilePath(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{blob: fileBlob{path: path}, now: now}, nil
|
||||
case "encrypted-file":
|
||||
path, err := resolveStoreFilePath(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
|
||||
// a sibling ".secret" file (see encrypt.go).
|
||||
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
|
||||
case "keyring":
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
osKeyring := keyring.New()
|
||||
if !osKeyring.Available() {
|
||||
return nil, fmt.Errorf("oauth: keyring storage requested but not available on %s; use file storage", runtime.GOOS)
|
||||
}
|
||||
kr = osKeyring
|
||||
}
|
||||
// Serialize the keyring's read-modify-write across processes with a lock
|
||||
// file beside where the file backend would live. Best-effort: if no config
|
||||
// location resolves, fall back to in-process serialization only.
|
||||
lockPath := ""
|
||||
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
|
||||
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
|
||||
}
|
||||
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStoreFilePath resolves the absolute file path for the file backend.
|
||||
func resolveStoreFilePath(options StoreOptions) (string, error) {
|
||||
filePath := options.FilePath
|
||||
var err error
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath, err = ResolveStorePath(options.Env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.blob.withLock(s.now, func() error {
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Tokens[key] = token
|
||||
return s.writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
// Load returns the token for key; the bool is false when none is stored.
|
||||
func (s *Store) Load(key string) (Token, bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
token, ok := state.Tokens[key]
|
||||
return token, ok, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for key, reporting whether one was present.
|
||||
func (s *Store) Delete(key string) (bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var removed bool
|
||||
err := s.blob.withLock(s.now, func() error {
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := state.Tokens[key]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(state.Tokens, key)
|
||||
removed = true
|
||||
return s.writeState(state)
|
||||
})
|
||||
return removed, err
|
||||
}
|
||||
|
||||
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
||||
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
|
||||
func (s *Store) Status(prefix string) ([]Status, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(state.Tokens))
|
||||
for k := range state.Tokens {
|
||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
now := s.now()
|
||||
out := make([]Status, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
token := state.Tokens[k]
|
||||
out = append(out, Status{
|
||||
Key: k,
|
||||
HasToken: strings.TrimSpace(token.AccessToken) != "",
|
||||
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
|
||||
TokenType: token.TokenType,
|
||||
Account: token.Account,
|
||||
Scopes: token.Scopes,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
Expired: token.Expired(now),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) readState() (storeFile, error) {
|
||||
data, ok, err := s.blob.read()
|
||||
if err != nil {
|
||||
return storeFile{}, err
|
||||
}
|
||||
if !ok {
|
||||
return emptyStoreFile(), nil
|
||||
}
|
||||
if s.crypter != nil {
|
||||
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
|
||||
data, err = s.crypter.open(data)
|
||||
if err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
}
|
||||
var state storeFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
if state.SchemaVersion != storeSchemaVersion {
|
||||
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 store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeState(state storeFile) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Plaintext keeps the trailing newline for a tidy file; the encrypted backend
|
||||
// writes opaque ciphertext instead.
|
||||
payload := append(data, '\n')
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.seal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.blob.write(payload)
|
||||
}
|
||||
|
||||
func emptyStoreFile() storeFile {
|
||||
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
|
||||
}
|
||||
|
||||
// blobStore abstracts the persistence of the whole token blob behind the Store,
|
||||
// so the same store logic backs either a 0600 file or the OS keyring.
|
||||
type blobStore interface {
|
||||
// read returns the stored blob; ok is false when nothing is stored yet.
|
||||
read() (data []byte, ok bool, err error)
|
||||
// write replaces the stored blob.
|
||||
write(data []byte) error
|
||||
// withLock runs fn under whatever cross-process exclusion the backend offers
|
||||
// (a lock file for the file backend; none for the keyring, which is the
|
||||
// authoritative store and is serialized within the process by Store.mu).
|
||||
withLock(now func() time.Time, fn func() error) error
|
||||
// location is a human-readable identifier for diagnostics/errors.
|
||||
location() string
|
||||
}
|
||||
|
||||
// fileBlob persists the blob as a 0600 JSON file, written atomically and guarded
|
||||
// by a cross-process lock file. Behavior matches the original file store.
|
||||
type fileBlob struct{ path string }
|
||||
|
||||
func (b fileBlob) read() ([]byte, bool, error) {
|
||||
data, err := os.ReadFile(b.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (b fileBlob) write(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
|
||||
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, b.path); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
unlock, err := acquireFileLock(b.path+".lockfile", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (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 {
|
||||
if len(statuses) == 0 {
|
||||
return "No OAuth provider logins are stored."
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, st := range statuses {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
name := strings.TrimPrefix(st.Key, KeyPrefixProvider)
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
if !st.HasToken {
|
||||
b.WriteString("no token")
|
||||
continue
|
||||
}
|
||||
b.WriteString("logged in")
|
||||
if st.Account != "" {
|
||||
b.WriteString(" as " + st.Account)
|
||||
}
|
||||
if st.HasRefreshToken {
|
||||
b.WriteString(" (refreshable)")
|
||||
}
|
||||
if !st.ExpiresAt.IsZero() {
|
||||
if st.Expired {
|
||||
b.WriteString(", expired at ")
|
||||
} else {
|
||||
b.WriteString(", expires ")
|
||||
}
|
||||
b.WriteString(st.ExpiresAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envValue reads a variable. A non-nil env map is authoritative (hermetic): a
|
||||
// missing key returns "" rather than falling back to the process environment, so
|
||||
// a caller/test that passes a controlled map can never pick up ambient
|
||||
// ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME values. Only a nil map uses os.Getenv.
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
func TestStoreSaveLoadDelete(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
tok := Token{AccessToken: "at", RefreshToken: "rt", Account: "me@x"}
|
||||
if err := s.Save(ProviderKey("demo"), tok); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Save(KeyPrefixMCP+"server1", Token{AccessToken: "mcp-at"}); err != nil {
|
||||
t.Fatalf("Save mcp: %v", err)
|
||||
}
|
||||
got, ok, err := s.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || got.AccessToken != "at" || got.Account != "me@x" {
|
||||
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
removed, err := s.Delete(ProviderKey("demo"))
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Delete = %v %v", removed, err)
|
||||
}
|
||||
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
|
||||
t.Fatal("token should be gone after delete")
|
||||
}
|
||||
// The mcp-namespaced token is untouched.
|
||||
if _, ok, _ := s.Load(KeyPrefixMCP + "server1"); !ok {
|
||||
t.Fatal("mcp token should survive provider delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidKeys(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
for _, bad := range []string{"demo", "provider:", "provider:../escape", "mcp:bad/key", "other:x", ""} {
|
||||
if err := s.Save(bad, Token{AccessToken: "x"}); err == nil {
|
||||
t.Errorf("Save(%q) should be rejected", bad)
|
||||
}
|
||||
}
|
||||
for _, ok := range []string{"provider:demo", "mcp:server-1", "provider:two-svc"} {
|
||||
if err := ValidateKey(ok); err != nil {
|
||||
t.Errorf("ValidateKey(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreStatusFiltersByPrefix(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
_ = s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
_ = s.Save(KeyPrefixMCP+"srv", Token{AccessToken: "m"})
|
||||
statuses, err := s.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") {
|
||||
t.Fatalf("provider status = %+v", statuses)
|
||||
}
|
||||
if !statuses[0].HasRefreshToken {
|
||||
t.Fatal("status should report a refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFileMode0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix file modes")
|
||||
}
|
||||
s, path := newTestStore(t)
|
||||
if err := s.Save(ProviderKey("x"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("token file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMalformedFailsClosed(t *testing.T) {
|
||||
s, path := newTestStore(t)
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt: %v", err)
|
||||
}
|
||||
if _, _, err := s.Load(ProviderKey("x")); err == nil {
|
||||
t.Fatal("malformed store must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvValueHermetic(t *testing.T) {
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "ambient")
|
||||
// A non-nil map omitting the key must NOT leak the ambient process value.
|
||||
if got := envValue(map[string]string{}, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "" {
|
||||
t.Fatalf("non-nil env map must be hermetic, got %q", got)
|
||||
}
|
||||
// A nil map reads the process environment.
|
||||
if got := envValue(nil, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "ambient" {
|
||||
t.Fatalf("nil env map should read os env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorePathHonorsOverride(t *testing.T) {
|
||||
// Use an OS-appropriate absolute path: a unix-style "/tmp/..." literal is not
|
||||
// absolute on Windows (no drive), so it would be resolved against the current
|
||||
// drive and a verbatim comparison would fail there.
|
||||
override := filepath.Join(t.TempDir(), "custom", "tok.json")
|
||||
path, err := ResolveStorePath(map[string]string{"ZERO_OAUTH_TOKENS_PATH": override})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveStorePath: %v", err)
|
||||
}
|
||||
if path != override {
|
||||
t.Fatalf("path = %q, want %q", path, override)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -378,6 +378,8 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// Short retry so a freed lock is re-acquired promptly under heavy
|
||||
// contention (Windows file ops are slow; a coarse sleep starves waiters).
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -246,10 +246,11 @@ func TestMailboxLockReleaseIsOwnershipAware(t *testing.T) {
|
||||
|
||||
func TestMailboxConcurrentSends(t *testing.T) {
|
||||
mb := newTestMailbox(t)
|
||||
// High fan-out + a generous lock timeout so heavy contention is exercised
|
||||
// (and a lock regression — e.g. a Windows sharing-violation treated as fatal —
|
||||
// would surface as lost/failed messages here rather than flaking).
|
||||
mb.LockTimeout = 10 * time.Second
|
||||
// High fan-out exercises heavy contention (a lock regression — e.g. a Windows
|
||||
// sharing-violation treated as fatal — surfaces as lost/failed messages here).
|
||||
// The lock timeout is generous so a slow CI (Windows file ops are slow under
|
||||
// 200-way contention) never times a legitimate send out and flakes.
|
||||
mb.LockTimeout = 60 * time.Second
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
var failures atomic.Int32
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -29,6 +31,9 @@ type searchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Snippet string
|
||||
// Score is an optional provider-supplied relevance signal in the [0, 1]
|
||||
// range. Zero (the zero value) is treated as "absent" by the renderer.
|
||||
Score float64
|
||||
}
|
||||
|
||||
// searchBackend discovers URLs for a query. It is an interface so any hosted
|
||||
@@ -67,6 +72,11 @@ func newWebSearchToolWithBackend(backend searchBackend) Tool {
|
||||
Minimum: intPtr(1),
|
||||
Maximum: intPtr(maxWebSearchLimit),
|
||||
},
|
||||
"domains": {
|
||||
Type: "array",
|
||||
Description: "Optional list of allowed hostnames; results outside these hosts are filtered out before they reach the model. Useful as a prompt-injection defense when you only want results from a known set of domains.",
|
||||
Items: &PropertySchema{Type: "string"},
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
@@ -123,6 +133,17 @@ func (tool webSearchTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
if limit > maxWebSearchLimit {
|
||||
limit = maxWebSearchLimit
|
||||
}
|
||||
domains, domainsProvided, err := stringListArgWebSearch(args, "domains")
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for web_search: " + err.Error())
|
||||
}
|
||||
// Fail closed: the caller asked for an allowlist, but every entry was
|
||||
// invalid (or the list was empty). Silently dropping the filter would
|
||||
// turn a constrained search into an unconstrained one, defeating the
|
||||
// prompt-injection defense the parameter exists to provide.
|
||||
if domainsProvided && len(domains) == 0 {
|
||||
return errorResult("Error: web_search 'domains' argument was provided but contained no valid hostnames; remove the argument or pass valid hostnames")
|
||||
}
|
||||
|
||||
if tool.backend == nil {
|
||||
return errorResult("Error: no search backend configured. Set ZERO_WEBSEARCH_BASE_URL (and ZERO_WEBSEARCH_API_KEY) to enable web_search.")
|
||||
@@ -135,6 +156,16 @@ func (tool webSearchTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
if err != nil {
|
||||
return errorResult("Error performing web search: " + redactWebSearchText(err.Error()))
|
||||
}
|
||||
// Filter by domains BEFORE the empty-result short-circuit so a non-empty
|
||||
// allowlist that ate every hit surfaces as a clear "no results matched
|
||||
// domains" error rather than a misleading "no results" message.
|
||||
if len(results) > 0 && len(domains) > 0 {
|
||||
filtered, err := filterWebSearchByDomains(results, domains)
|
||||
if err != nil {
|
||||
return errorResult("Error: " + redactWebSearchText(err.Error()))
|
||||
}
|
||||
results = filtered
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return okResult("No results for query: " + redactWebSearchText(query))
|
||||
}
|
||||
@@ -146,6 +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 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 {
|
||||
@@ -153,7 +187,14 @@ func formatSearchResults(results []searchResult) string {
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s — %s", index+1, title, strings.TrimSpace(result.URL)))
|
||||
header := fmt.Sprintf("%d. %s — %s", index+1, title, strings.TrimSpace(result.URL))
|
||||
// Gate on the *rounded* value: a raw "> 0" check would still render
|
||||
// "score 0.00" for tiny positives like 0.0001, the exact noise the
|
||||
// score suffix is meant to avoid.
|
||||
if rounded := math.Round(result.Score*100) / 100; rounded >= 0.01 {
|
||||
header += fmt.Sprintf(" — score %.2f", rounded)
|
||||
}
|
||||
lines = append(lines, header)
|
||||
if snippet := strings.TrimSpace(result.Snippet); snippet != "" {
|
||||
lines = append(lines, " "+snippet)
|
||||
}
|
||||
@@ -260,13 +301,39 @@ func (backend *httpSearchBackend) Search(ctx context.Context, query string, limi
|
||||
return parseSearchResults(body)
|
||||
}
|
||||
|
||||
// parseSearchResults accepts either a bare array [{title,url,snippet}] or a
|
||||
// wrapped object {"results":[...]}, the two shapes common across providers.
|
||||
// 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"`
|
||||
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 {
|
||||
@@ -276,7 +343,12 @@ func parseSearchResults(body []byte) ([]searchResult, error) {
|
||||
convert := func(raw []rawResult) []searchResult {
|
||||
out := make([]searchResult, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
out = append(out, searchResult{Title: item.Title, URL: item.URL, Snippet: item.Snippet})
|
||||
out = append(out, searchResult{
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Snippet,
|
||||
Score: float64(item.Score),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -300,3 +372,128 @@ func parseSearchResults(body []byte) ([]searchResult, error) {
|
||||
func redactWebSearchText(value string) string {
|
||||
return redaction.RedactString(value, redaction.Options{})
|
||||
}
|
||||
|
||||
// stringListArgWebSearch reads an optional []string argument. The conventional
|
||||
// `[]any` shape is preferred (matches the rest of the tools package) but some
|
||||
// providers' tool-calling shapes emit `[]string` directly; tolerate both.
|
||||
//
|
||||
// The returned provided bool is true when the caller passed a "domains" key at
|
||||
// all (even with an empty array or all-invalid entries). That lets Run
|
||||
// distinguish "no allowlist" from "allowlist that normalized to empty" — the
|
||||
// latter is a fail-closed error, not a silent permission to skip filtering.
|
||||
func stringListArgWebSearch(args map[string]any, key string) (domains []string, provided bool, err error) {
|
||||
value, ok := args[key]
|
||||
if !ok || value == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
provided = true
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
if asString, ok2 := value.([]string); ok2 {
|
||||
return normalizeWebSearchDomainList(asString), true, nil
|
||||
}
|
||||
return nil, true, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for index, item := range raw {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, true, fmt.Errorf("%s[%d] must be a string", key, index)
|
||||
}
|
||||
out = append(out, text)
|
||||
}
|
||||
return normalizeWebSearchDomainList(out), true, nil
|
||||
}
|
||||
|
||||
// normalizeWebSearchDomainList lowercases, strips scheme/www/path fragments,
|
||||
// drops empty entries, and de-duplicates while preserving input order.
|
||||
func normalizeWebSearchDomainList(domains []string) []string {
|
||||
seen := make(map[string]struct{}, len(domains))
|
||||
out := make([]string, 0, len(domains))
|
||||
for _, raw := range domains {
|
||||
host := canonicalizeWebSearchHost(raw)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
out = append(out, host)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canonicalizeWebSearchHost extracts a bare lowercase hostname from a domain
|
||||
// string, accepting "react.dev", "https://react.dev/x", and "www.React.dev"
|
||||
// forms. Returns "" on empty/whitespace input. The "www." prefix is stripped
|
||||
// before lowercasing so case-insensitive matches like "WWW.react.dev" work.
|
||||
func canonicalizeWebSearchHost(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(trimmed, "://") {
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// Hostname() strips the port so allowlist entries like
|
||||
// "https://react.dev:443/path" normalize the same as "react.dev".
|
||||
// Using parsed.Host here would leave ":443" in the string and
|
||||
// silently break every match against a result URL on port 443.
|
||||
trimmed = parsed.Hostname()
|
||||
}
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
if i := strings.IndexAny(trimmed, "/?#"); i >= 0 {
|
||||
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 ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// filterWebSearchByDomains drops results whose host is not in the allowlist.
|
||||
// A result whose URL fails to parse is dropped (fail-closed). When the filter
|
||||
// eats every result, the error names the failing allowlist so the caller knows
|
||||
// the constraint, not the provider, is the cause.
|
||||
func filterWebSearchByDomains(results []searchResult, allowed []string) ([]searchResult, error) {
|
||||
allowSet := make(map[string]struct{}, len(allowed))
|
||||
for _, d := range allowed {
|
||||
allowSet[d] = struct{}{}
|
||||
}
|
||||
out := make([]searchResult, 0, len(results))
|
||||
for _, row := range results {
|
||||
host := hostFromWebSearchURL(row.URL)
|
||||
if host == "" {
|
||||
// Unparseable URL: never let it through when an allowlist is set.
|
||||
continue
|
||||
}
|
||||
if _, ok := allowSet[host]; !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no web_search results matched domains=%v; loosen the allowlist or remove the domains argument", allowed)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hostFromWebSearchURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed == nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,3 +258,452 @@ func TestSameHostRedirectPolicy(t *testing.T) {
|
||||
t.Fatal("redirect limit must be enforced")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- new: domains allowlist + score field ----------------------------------
|
||||
|
||||
func TestWebSearchDomainsFilterKeepsOnlyAllowedHosts(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "RSC", URL: "https://react.dev/rsc", Snippet: "Server components"},
|
||||
{Title: "RFC", URL: "https://github.com/reactjs/rfcs", Snippet: "RFC 0188"},
|
||||
{Title: "Other", URL: "https://stackoverflow.com/q/1", Snippet: "unrelated"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "react server components",
|
||||
"domains": []any{"react.dev", "github.com"},
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %v, output = %q", res.Status, res.Output)
|
||||
}
|
||||
if strings.Contains(res.Output, "stackoverflow.com") {
|
||||
t.Errorf("stackoverflow result must be filtered out, got: %s", res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "react.dev/rsc") || !strings.Contains(res.Output, "github.com/reactjs/rfcs") {
|
||||
t.Errorf("allowed-host results missing, got: %s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterEmptyResultIsError(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "x", URL: "https://stackoverflow.com/q/1"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": []any{"react.dev"},
|
||||
})
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected error when allowlist eats every result, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "no web_search results matched domains") {
|
||||
t.Errorf("expected clear allowlist error, got: %s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterToleratesStringSlice(t *testing.T) {
|
||||
// Some providers send []string instead of []any. The tool must accept both.
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ok", URL: "https://react.dev/x"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": []string{"react.dev"},
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected ok for []string domains, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterNormalizesSchemeAndCase(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ok", URL: "https://React.Dev/x"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": []any{"https://REACT.dev/path?ignored=1", "WWW.react.dev"},
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected ok after normalization, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterStripsWWWFromResult(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ok", URL: "https://www.react.dev/x"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
// Allowlist "react.dev" must still match a result that has www. in the URL.
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": []any{"react.dev"},
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected ok when result host has www., got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterRejectsMalformedURLs(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ok", URL: "https://react.dev/x"},
|
||||
{Title: "bad", URL: "::::not a url::::"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": []any{"react.dev"},
|
||||
})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %v, output = %s", res.Status, res.Output)
|
||||
}
|
||||
if strings.Contains(res.Output, "not a url") {
|
||||
t.Errorf("malformed URL must be filtered out, got: %s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchDomainsFilterRejectsBadArgType(t *testing.T) {
|
||||
tool := newWebSearchToolWithBackend(&fakeSearchBackend{})
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": "react.dev", // string instead of array
|
||||
})
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected error for non-array domains, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchRendersScoreWhenPresent(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ranked", URL: "https://react.dev/rsc", Score: 0.91},
|
||||
{Title: "unranked", URL: "https://react.dev/other"}, // zero score, must be omitted
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
res := tool.Run(context.Background(), map[string]any{"query": "x"})
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("status = %v, output = %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "score 0.91") {
|
||||
t.Errorf("expected 'score 0.91' in output, got: %s", res.Output)
|
||||
}
|
||||
// The zero-score row must NOT render "score 0.00" — that would be noisy.
|
||||
if strings.Contains(res.Output, "score 0.00") {
|
||||
t.Errorf("zero score must not be rendered, got: %s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSearchBackendParsesScoreField(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"title":"A","url":"https://a.test","snippet":"sa","score":0.77},{"title":"B","url":"https://b.test","snippet":"sb"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
backend := &httpSearchBackend{client: server.Client(), baseURL: server.URL}
|
||||
results, err := backend.Search(context.Background(), "q", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
if results[0].Score != 0.77 {
|
||||
t.Errorf("first result Score = %v, want 0.77", results[0].Score)
|
||||
}
|
||||
if results[1].Score != 0 {
|
||||
t.Errorf("absent score must be zero, got %v", results[1].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeWebSearchHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"react.dev", "react.dev"},
|
||||
{" REACT.dev ", "react.dev"},
|
||||
{"https://react.dev/x", "react.dev"},
|
||||
{"http://www.react.dev", "react.dev"},
|
||||
{"WWW.react.dev", "react.dev"},
|
||||
{"react.dev/path?x=1", "react.dev"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"https://", ""},
|
||||
{"react .dev", ""}, // space inside
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := canonicalizeWebSearchHost(c.in); got != c.want {
|
||||
t.Errorf("canonicalizeWebSearchHost(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWebSearchDomainList(t *testing.T) {
|
||||
got := normalizeWebSearchDomainList([]string{" REACT.dev ", "https://github.com/x", "www.example.com", "react.dev", "", " "})
|
||||
want := []string{"react.dev", "github.com", "example.com"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("normalizeWebSearchDomainList = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterWebSearchByDomains(t *testing.T) {
|
||||
rows := []searchResult{
|
||||
{URL: "https://react.dev/x"},
|
||||
{URL: "https://github.com/y"},
|
||||
{URL: "https://stackoverflow.com/z"},
|
||||
}
|
||||
filtered, err := filterWebSearchByDomains(rows, []string{"react.dev", "github.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("filtered len = %d, want 2; got %v", len(filtered), filtered)
|
||||
}
|
||||
// Empty allowlist must be rejected when called directly with no rows that match.
|
||||
_, err = filterWebSearchByDomains([]searchResult{{URL: "https://x.test"}}, []string{"nope.test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when allowlist is non-empty and no rows match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSearchSchemaHasDomainsField(t *testing.T) {
|
||||
tool := NewWebSearchTool()
|
||||
domains := tool.Parameters().Properties["domains"]
|
||||
if domains.Type != "array" {
|
||||
t.Fatalf("domains.Type = %q, want array", domains.Type)
|
||||
}
|
||||
if domains.Items == nil || domains.Items.Type != "string" {
|
||||
t.Fatalf("domains.Items = %#v, want array of string", domains.Items)
|
||||
}
|
||||
if domains.Description == "" {
|
||||
t.Fatal("domains.Description should explain the prompt-injection defense")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchDomainsFilterRejectsAllInvalidInputs covers the fail-closed
|
||||
// contract: when the caller passes a 'domains' argument but every entry is
|
||||
// invalid (e.g. all whitespace, all with embedded spaces), the tool must
|
||||
// error rather than silently return unfiltered results. The whole point of
|
||||
// the parameter is the prompt-injection defense; an unfiltered result set
|
||||
// would defeat it.
|
||||
func TestWebSearchDomainsFilterRejectsAllInvalidInputs(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "x", URL: "https://react.dev/x"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
domains []any
|
||||
}{
|
||||
{"all whitespace", []any{" ", "\t", "\n"}},
|
||||
{"empty strings", []any{"", ""}},
|
||||
{"embedded spaces", []any{"react .dev", "github .com"}},
|
||||
{"mixed invalid", []any{"", " ", "react .dev"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": c.domains,
|
||||
})
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected error when all domains are invalid, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "no valid hostnames") {
|
||||
t.Errorf("expected 'no valid hostnames' in error, got: %s", res.Output)
|
||||
}
|
||||
if backend.gotQuery != "" {
|
||||
t.Errorf("backend must not have been called when domains are invalid; got query %q", backend.gotQuery)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchDomainsFilterAcceptsHostPort covers the canonicalization
|
||||
// contract: an allowlist entry that includes a port WITH a scheme (e.g. the
|
||||
// result of a model passing "https://react.dev:443/path") must still match
|
||||
// a result URL on the same hostname. The CodeRabbit-flagged bug was that
|
||||
// the canonicalizer was using parsed.Host (which includes ":443") rather
|
||||
// than parsed.Hostname() (which strips the port), so a "react.dev" allowlist
|
||||
// silently never matched results from "https://react.dev:443/...". The fix
|
||||
// uses Hostname(), so this test locks in the corrected contract.
|
||||
func TestWebSearchDomainsFilterAcceptsHostPort(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
allowlist []any
|
||||
expectMatch bool
|
||||
}{
|
||||
{"with explicit https port", []any{"https://react.dev:443/path"}, true},
|
||||
{"with explicit http port", []any{"http://react.dev:80/x"}, true},
|
||||
{"scheme-less host:port is rejected", []any{"react.dev:9999"}, false}, // not a valid hostname
|
||||
{"port on different host", []any{"github.com:443"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
backend := &fakeSearchBackend{results: []searchResult{
|
||||
{Title: "ok", URL: "https://react.dev/x"},
|
||||
}}
|
||||
tool := newWebSearchToolWithBackend(backend)
|
||||
res := tool.Run(context.Background(), map[string]any{
|
||||
"query": "x",
|
||||
"domains": c.allowlist,
|
||||
})
|
||||
if c.expectMatch {
|
||||
if res.Status != StatusOK {
|
||||
t.Fatalf("expected match, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
if !strings.Contains(res.Output, "react.dev/x") {
|
||||
t.Errorf("expected react.dev result, got: %s", res.Output)
|
||||
}
|
||||
} else {
|
||||
if res.Status != StatusError {
|
||||
t.Fatalf("expected no-match error, got %v: %s", res.Status, res.Output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeWebSearchHostStripsPort(t *testing.T) {
|
||||
// Lock in the contract that parsed.Host (which includes ":443") is NOT
|
||||
// used; parsed.Hostname() is. CodeRabbit flagged this inconsistency.
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"https://react.dev:443/x", "react.dev"},
|
||||
{"http://react.dev:80", "react.dev"},
|
||||
{"https://api.react.dev:8443/v1", "api.react.dev"},
|
||||
{"https://react.dev", "react.dev"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := canonicalizeWebSearchHost(c.in); got != c.want {
|
||||
t.Errorf("canonicalizeWebSearchHost(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user