mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1705d3260e | ||
|
|
6df5c2b4cb | ||
|
|
b7f90a787f | ||
|
|
b1cf24831d | ||
|
|
0b496ce87d | ||
|
|
724167e90e | ||
|
|
6092303e73 | ||
|
|
1e25fde3b1 | ||
|
|
7fae5c530f | ||
|
|
7af2a1b3b6 | ||
|
|
9c1cc69b59 | ||
|
|
a0b9876b69 | ||
|
|
c38444157c | ||
|
|
75ecf8e385 | ||
|
|
efb6b79164 | ||
|
|
2bcb85bfb3 | ||
|
|
e1253c826c | ||
|
|
f857c4afb4 | ||
|
|
bb7de29c80 | ||
|
|
de5d402e1f |
No files matched your search
@@ -1,16 +1,311 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
description: Project-level instruction set for the AI agent working in this repo. Ten discrete modes covering the recurring engineering workflows on Zero.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# AGENTS.md
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
Instructions for the AI agent working inside the `zero` repository. Read this before any non-trivial change.
|
||||
|
||||
## 1. Repository map
|
||||
|
||||
### Entry points
|
||||
|
||||
- `cmd/zero` — primary CLI binary. Run with `go run ./cmd/zero`.
|
||||
- `cmd/zero-release` — release tooling. `build`, `smoke`, `package`, `verify`. Never bypass.
|
||||
- `cmd/zero-seccomp` — Linux seccomp profile generator for the sandbox.
|
||||
- `cmd/zero-perf-bench` — performance smoke work.
|
||||
- `cmd/zero-pr-review` — local PR-review runner.
|
||||
- `bin/zero.js` — npm wrapper around the Go binary. Touch only when adjusting the npm surface.
|
||||
|
||||
### Core internal packages
|
||||
|
||||
- `agent/` — the agent loop. Prompt in, tool calls out, results back, repeat. `guardrails.go` holds the loop's stop and confirmation thresholds. `loop.go` is the main state machine.
|
||||
- `tui/` — terminal UI built on `charm.land/bubbletea/v2` and `charm.land/lipgloss/v2`. `model.go` holds state, `view.go` is pure render, `command_center.go` dispatches slash commands, `theme.go` owns every color and border (hex literals are forbidden elsewhere).
|
||||
- `providers/` — LLM adapters. One subdir per provider: `openai/`, `anthropic/`, `gemini/`, `providerio/`. `factory.go` registers them.
|
||||
- `sandbox/` — exec gating and risk classification. `risk.go` defines patterns, `engine.go` decides allow/deny, `paths.go` and `pathlists.go` handle allow/deny lists.
|
||||
- `sessions/` — session state, persistence, resume, title generation.
|
||||
- `specialist/` — sub-agent routing. Specialists run scoped tasks with their own tool whitelist. `builtin.go` declares the interface, `runtime.go` runs them, `manifest.go` and `exec.go` are the execution paths.
|
||||
- `swarm/` — multi-agent coordination for batched work.
|
||||
- `mcp/` — Model Context Protocol client and tool bridge.
|
||||
- `modelregistry/` — model catalog, capability flags (vision, tool calls, reasoning efforts), cost data. `catalog.go` is `DefaultModelEntries`.
|
||||
- `providercatalog/` — provider descriptors (base URLs, auth env vars, default model).
|
||||
- `tools/` — agent-callable tools. Each tool implements the `Tool` interface in `tools/types.go`. `registry.go` is the central map; `CoreToolsScoped` / `CoreReadOnlyToolsScoped` / `CoreWriteToolsScoped` / `CoreShellToolsScoped` / `CoreNetworkTools` are the registration sites.
|
||||
- `cli/` — top-level command wiring. `app.go` is the entry, `command_center.go` dispatches subcommands, per-feature files (`exec.go`, `sessions.go`, `setup.go`, etc.) implement each subcommand.
|
||||
|
||||
Other packages worth knowing: `config/`, `redaction/`, `secrets/`, `imageinput/`, `cron/`, `doctor/`, `browser/`, `background/`, `lsp/`, `hooks/`, `update/`, `usage/`, `verify/`, `testrunner/`, `selfverify/`, `workspaceindex/`, `worktrees/`, `workspaceseed/`, `specmode/`, `streamjson/`, `notify/`, `keyring/`, `observability/`, `oauth/`, `provideroauth/`, `provideronboarding/`, `providerhealth/`, `providermodelcatalog/`, `providermodeldiscovery/`, `npmwrapper/`, `agenteval/`, `zerocommands/`, `zerogit/`, `zeroruntime/`, `release/`, `review/`, `repomap/`, `repoinfo/`, `search/`, `skills/`, `plugins/`.
|
||||
|
||||
### Test surface
|
||||
|
||||
- Tests live next to the file they cover (`foo.go` → `foo_test.go`). No separate `tests/` tree.
|
||||
- Snapshot tests use `charmbracelet/x/exp/golden` (visible in `go.sum`).
|
||||
- `go test ./internal/<pkg>/ -run TestX -v` for narrow runs; `go test ./...` for the full suite.
|
||||
- No `-race` on Windows (no gcc toolchain). On Linux/macOS, `-race` is fine.
|
||||
|
||||
## 2. Conventions
|
||||
|
||||
- Go 1.25, toolchain `go1.26.4`. Charm libs are pinned to v2 (`charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`).
|
||||
- Shell parsing uses `mvdan.cc/sh/v3`, not `os/exec` + string concatenation. Never invent your own shell tokenizer.
|
||||
- `gofmt -l <files>` and `go vet ./...` must pass before commit. CI enforces both.
|
||||
- The npm wrapper at `bin/zero.js` is the only Node-touching surface; everything else is Go-native. Do not add Bun or TypeScript build deps.
|
||||
- Theme tokens are the single source of truth for color and border. Hex literals anywhere except `internal/tui/theme.go` are rejected.
|
||||
- The TUI split is strict: `model.go` owns state, `view.go` is pure render. Styling calls (`Style.Render`, `NewStyle`) live in `view.go` and render helpers under `tui/`; `model.go` may call `lipgloss.Width` for column measurements but never applies styles.
|
||||
- Errors are wrapped with `fmt.Errorf("...: %w", err)` and surface context. Never swallow errors silently.
|
||||
- Public API changes require a one-line note in the PR body explaining the impact and the migration path.
|
||||
|
||||
## 3. Ten agent modes
|
||||
|
||||
Each mode is a discrete workflow the AI agent adopts when the task matches. Pick the mode at the start of the task; do not blend them.
|
||||
|
||||
### Mode 1 — Add a slash command
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/commands.go` — add a `commandDefinition` entry. Set `name`, `aliases`, `usage`, `group`, `description`, `kind` (one of the `commandKind` constants in this file).
|
||||
- `internal/tui/command_center.go` — add a `case command<Name>:` branch to the slash-command dispatch. Match the dispatch shape used by existing commands: read `command.text`, mutate model state, append a transcript row.
|
||||
- `internal/tui/<feature>.go` — co-locate the handler with the related feature. Handler signature is `(model, args) (model, string)` where the string is the status text the transcript displays.
|
||||
- `internal/tui/view.go` or `internal/tui/command_output.go` — only if the command renders a card or panel; reuse the existing `commandCard` and `commandOutput` helpers.
|
||||
|
||||
Pattern to follow: copy `commandEffort` end-to-end (definition in `commands.go`, dispatch in `command_center.go`, handler in `session_controls.go`). Do not invent a new shape.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not register the command in two places. One definition, one dispatch, one handler.
|
||||
- Do not read input from `m.input.Value()` inside the handler. The dispatch already passes `command.text`.
|
||||
- Do not mutate `m.picker` from inside a slash-command handler. Use `m.openXxxPicker()` if a picker is needed.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go build ./cmd/zero
|
||||
go test ./internal/tui/ -run Test<Command> -v
|
||||
```
|
||||
|
||||
### Mode 2 — Add a tool
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tools/<name>.go` — implement the `Tool` interface from `internal/tools/types.go`. Required methods: `Name()`, `Description()`, `Parameters()`, `Safety()`, `Run(ctx, args) Result`. Fill `Safety{ SideEffect, Permission, Reason }` honestly.
|
||||
- `internal/tools/registry.go` — register the tool by adding it to the appropriate `CoreToolsScoped` variant (read-only, write, shell, network, or full). The factory functions decide which tool set a context gets.
|
||||
- `internal/tools/<name>_test.go` — table-driven test covering the happy path, each error path declared in the schema, and one permission-boundary case.
|
||||
|
||||
Pattern to follow: copy `internal/tools/read_file.go` end-to-end. It demonstrates the `Tool` interface, the schema pattern, and the test layout.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not call `os/exec` directly. The bash tool is the only path; everything else routes through `mvdan.cc/sh/v3` indirectly or uses Go stdlib.
|
||||
- Do not mutate `m.transcript` from inside a tool. Tools return `Result`; the agent loop appends to the transcript.
|
||||
- Do not skip `Safety`. Every tool must declare `SideEffect` and `Permission`.
|
||||
- Do not add new top-level deps. If a tool needs a library, justify it in the PR body.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/tools/ -run Test<Name> -v
|
||||
go vet ./internal/tools/
|
||||
```
|
||||
|
||||
### Mode 3 — Add a provider
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/providers/<name>/` — new subdir. Mirror `internal/providers/openai/` (factory wiring, request types, response mapping, image / tool-call handling, tests).
|
||||
- `internal/providers/factory.go` — register the new provider in the factory switch.
|
||||
- `internal/providercatalog/catalog.go` — add a `Descriptor` entry (ID, display name, default base URL, auth env vars, default model, capabilities).
|
||||
- `internal/cli/...` — only if the provider needs a new flag. Mirror existing flags before adding new ones.
|
||||
|
||||
Pattern to follow: copy `internal/providers/openai/`. It has factory wiring, request shaping, content-part conversion, image blocks, and tests for every code path.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not skip the image / vision path. Even if your first model doesn't support vision, the provider must handle `zeroruntime.ImageBlock` correctly (Anthropic `image` block, OpenAI `image_url`, Gemini `inlineData`).
|
||||
- Do not invent your own HTTP client. Reuse the one in `internal/providers/openai/` or `internal/providerio/`.
|
||||
- Do not hardcode model IDs. They live in `internal/modelregistry/`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/providers/<name>/ -v
|
||||
go test ./internal/providers/ -v
|
||||
go test ./internal/providercatalog/ -v
|
||||
```
|
||||
|
||||
### Mode 4 — Add a model entry
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/modelregistry/catalog.go` — extend `DefaultModelEntries()` with the new model. Fields: ID, Provider, DisplayName, ContextWindow, Capabilities (`vision`, `tool_call`, `reasoning`), ReasoningEfforts (if applicable), Cost.
|
||||
|
||||
Pattern to follow: open `catalog.go`, find the existing entry for the same provider, copy the row, edit the values. Same shape, same fields.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not invent capabilities. Set only the ones the model actually supports. If unsure, leave the capability unset.
|
||||
- Do not add cost data you cannot cite. If you don't have a source, leave the cost fields zero and note it in the PR.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/modelregistry/ -v
|
||||
go test ./internal/tui/ -run TestModel -v
|
||||
```
|
||||
|
||||
### Mode 5 — Add a specialist
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/specialist/<name>.go` — implement the specialist. Read `internal/specialist/builtin.go` for the interface and `runtime.go` for the execution model.
|
||||
- `internal/specialist/registry.go` — register the specialist. Update the routing table so the main agent knows when to dispatch to it.
|
||||
|
||||
Pattern to follow: copy `internal/specialist/exec.go` (or another existing specialist). The shape is consistent.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not give a specialist access to tools the main agent doesn't. Specialists have their own whitelist.
|
||||
- Do not let a specialist call another specialist. One level of dispatch only.
|
||||
- Do not register a specialist without a routing rule. Routing without a rule is unreachable code.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/specialist/ -v
|
||||
go test ./internal/agent/ -v
|
||||
```
|
||||
|
||||
### Mode 6 — Add a sandbox rule
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/sandbox/risk.go` — add a pattern to the appropriate risk bucket (`destructive`, `destructive_catastrophic`, `network`, `credential`, etc.).
|
||||
- `internal/sandbox/engine.go` — only if the rule needs a new decision branch.
|
||||
- `internal/sandbox/risk_hardening_test.go` (or a sibling test file) — write a failing test FIRST that exercises the new pattern.
|
||||
|
||||
Pattern to follow: read `risk.go` to see the existing pattern classification. The `add()` helper takes `(pattern, RiskLevel)`. New rules sit alongside their category.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not weaken an existing rule. If you find a false positive, write a more specific pattern that excludes the safe case rather than removing the dangerous one.
|
||||
- Do not add a rule without a test. Sandbox regressions are silent and dangerous.
|
||||
- Do not skip `requestPaths` checks. When a command takes paths, the sandbox must validate them against the workspace + grant scope.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/sandbox/ -run TestRisk -v
|
||||
```
|
||||
|
||||
### Mode 7 — Modify the TUI
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/model.go` — state only. Add fields, add cases to `Update()`, add dispatch logic. Never apply lipgloss styles here.
|
||||
- `internal/tui/view.go` — pure render. Read from `model`, render to styled strings. Every style application lives here or in a `render*` helper.
|
||||
- `internal/tui/<feature>.go` — split when the feature is large. Look at the existing per-feature files (`picker.go`, `session.go`, `transcript_body.go`, etc.) for the convention.
|
||||
- `internal/tui/theme.go` — only if you need a new color or border. Add a named token with a hex value; never inline a hex elsewhere.
|
||||
|
||||
Pattern to follow: open `model.go` and `view.go` side-by-side. Find the feature you're extending. The state mutation lives in `model.go`; the renderer lives in `view.go`. Tests live in `*_test.go` next to each.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not apply lipgloss styles from `model.go`.
|
||||
- Do not store styled strings in the model. Store raw values; render at draw time.
|
||||
- Do not bypass `m.transcript`. Every visible row goes through `appendTranscriptRow`.
|
||||
- Do not add a hex color literal anywhere except `theme.go`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
gofmt -l internal/tui/
|
||||
go vet ./internal/tui/
|
||||
go test ./internal/tui/ -count=1
|
||||
```
|
||||
|
||||
### Mode 8 — Debug a failing test
|
||||
|
||||
Steps:
|
||||
|
||||
1. Run narrow: `go test ./internal/<pkg>/ -run Test<Name> -v -count=1`. Read the failure verbatim — the assertion message is the most reliable signal.
|
||||
2. Read the test. The test is the contract. Do not rewrite the test to match broken behavior.
|
||||
3. Read the production code under test. Find the divergence between what the test expects and what the code does.
|
||||
4. Fix the production code. If the production code is right and the test was wrong, fix the test — but only after proving the production behavior is correct.
|
||||
5. Run narrow again. Then run the package. Then run the adjacent packages. Then run `go test ./...` to catch regressions.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not `t.Skip()` to make a test pass. Skip with a TODO comment and a linked issue at most.
|
||||
- Do not delete a failing test without understanding why it was added.
|
||||
- Do not change a test's expected value without a commit message explaining why.
|
||||
- Do not modify `time.Now()` or any clock source to make a timing-sensitive test pass.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/<pkg>/ -run Test<Name> -v -count=1
|
||||
go test ./internal/<pkg>/ -count=1
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Mode 9 — Cut a release
|
||||
|
||||
Touch files:
|
||||
|
||||
- `cmd/zero-release` only. No manual `git tag`. No manual binary upload. No edits to `CHANGELOG.md` by hand.
|
||||
|
||||
Pattern to follow: run `go run ./cmd/zero-release build`, then `go run ./cmd/zero-release smoke`, then `go run ./cmd/zero-release package`, then `go run ./cmd/zero-release verify`. The release tooling is the contract.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not push a tag by hand. The release tooling owns tag creation.
|
||||
- Do not skip `smoke`. If smoke fails, the release fails.
|
||||
- Do not edit `CHANGELOG.md` manually. The release tooling generates it from `git log` since the previous tag.
|
||||
- Do not bypass `verify`. Verification is the last gate.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
```
|
||||
|
||||
### Mode 10 — Run a security or quality audit
|
||||
|
||||
Touch files (read-only by default; edits only after explicit user approval):
|
||||
|
||||
- `internal/sandbox/` — destructive-command classification in `risk.go`, decision logic in `engine.go`, path allow/deny in `paths.go` and `pathlists.go`. Audit coverage: do destructive paths actually get caught? Are grant scopes respected? Are `..` traversals blocked?
|
||||
- `internal/secrets/` — env handling. Audit: are secrets redacted in logs? Are `os.Getenv` calls going through the keyring when configured?
|
||||
- `internal/redaction/` — output redaction. Audit: is the redaction list current? Are there new tool outputs that need redaction entries?
|
||||
- `internal/providers/` — provider auth. Audit: are API keys read from env, never persisted? Are provider adapters validating their inputs?
|
||||
- `internal/mcp/` — MCP server trust. Audit: are MCP server outputs treated as data, never as instructions? Is the schema validated?
|
||||
- `internal/hooks/` — pre/post command hooks. Audit: do hooks have a way to abort? Is the abort path tested?
|
||||
- `docs/audit/` — read the most recent `*-deep-audit*.md` and `*-reverification.md` to see what's already been reviewed. Do not re-audit a category already covered.
|
||||
|
||||
Pattern to follow: pick one subsystem per audit pass. Read the code, run the existing tests, write a one-page report naming each finding with a `file:line` anchor and an exploitability classification (`exploitable | plausible | theoretical`).
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not audit and edit in the same pass. Audit first, get user sign-off, then fix.
|
||||
- Do not propose fixes that disable a security control to silence a false positive. Tighten the rule instead.
|
||||
- Do not skip the audit because a recent audit covered the area. Confirm the diff since the last audit is empty.
|
||||
- Do not write findings without a `file:line` anchor. Vague findings get deleted.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/secrets/ -v
|
||||
go test ./internal/redaction/ -v
|
||||
go test ./internal/mcp/ -v
|
||||
```
|
||||
|
||||
## 4. Hard rules across all modes
|
||||
|
||||
- Never run `rm -rf`, `git reset --hard`, or force-push without an explicit user message.
|
||||
- Never edit a file you have not read in full first.
|
||||
- Never widen `Permission` from `prompt` to `allow` for a tool without a test that proves the wider permission is safe.
|
||||
- Never delete a test. The test is a contract.
|
||||
- Never bypass `go vet` or `gofmt` with a `// nolint:` comment unless the PR body explains why.
|
||||
- Never commit secrets, even temporarily. If a secret lands in a commit, revert the commit and rotate the secret.
|
||||
@@ -0,0 +1,184 @@
|
||||
# OAuth logins & using ChatGPT / Claude subscriptions
|
||||
|
||||
Zero supports two distinct things people mean by "log in with OAuth":
|
||||
|
||||
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
|
||||
fully built in (`zero auth login …`). Zero attaches the token to model calls
|
||||
automatically.
|
||||
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
|
||||
pay-per-token API key — only possible through a **local proxy**, for the
|
||||
reasons documented below. Zero ships a convenience preset and this recipe.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth login for a provider or gateway (built in)
|
||||
|
||||
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
|
||||
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
|
||||
env vars and log in:
|
||||
|
||||
```sh
|
||||
export ZERO_OAUTH_ACME_CLIENT_ID=…
|
||||
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
|
||||
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
|
||||
export ZERO_OAUTH_ACME_SCOPES="openid profile"
|
||||
zero auth login acme # browser (loopback); --device for headless
|
||||
zero auth status
|
||||
```
|
||||
|
||||
When a login exists for a provider, the **OpenAI and Anthropic** providers send
|
||||
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
|
||||
`401`) instead of the API key. With no login they use the API key exactly as
|
||||
before. Tokens are stored 0600 (or the OS keyring with
|
||||
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
|
||||
|
||||
### In the setup wizard (`/provider`)
|
||||
|
||||
Running `/provider` opens a **"How do you want to connect?"** chooser:
|
||||
|
||||
```text
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
|
||||
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
|
||||
```
|
||||
|
||||
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
|
||||
|
||||
```text
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
```
|
||||
|
||||
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
|
||||
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
|
||||
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
|
||||
**d** on the list to get a code to enter on another device instead of opening a
|
||||
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
|
||||
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
|
||||
equivalent is `zero auth login xai --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
|
||||
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
|
||||
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
|
||||
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
|
||||
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
|
||||
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
|
||||
the provider profile and used normally.
|
||||
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
|
||||
ships a built-in preset for the public Grok-CLI client, but to keep third-party
|
||||
client identities out of the default credential path it is **off by default**.
|
||||
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
|
||||
(browser, or `--device` for headless) works one-click; the token is used directly
|
||||
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
|
||||
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
|
||||
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
|
||||
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
|
||||
Grok-CLI client that may change without notice.
|
||||
|
||||
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
|
||||
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
|
||||
`zero auth login <name>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ChatGPT / Claude subscriptions — why a proxy is required
|
||||
|
||||
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
|
||||
**not** work as a drop-in bearer against the standard APIs:
|
||||
|
||||
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
|
||||
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
|
||||
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
|
||||
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
|
||||
requires mimicking the official Codex client (originator + account-id header).
|
||||
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
|
||||
for third-party use unless the request spoofs the Claude Code identity
|
||||
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
|
||||
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
|
||||
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
|
||||
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
|
||||
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
|
||||
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
|
||||
then on **April 4 2026** enforcement landed and subscription OAuth tokens
|
||||
**stopped working in third-party harnesses** (starting with OpenClaw, then the
|
||||
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
|
||||
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
|
||||
both per-token, not the flat subscription. The request to allow subscription use
|
||||
(claude-code #37205) was closed *"not planned."*
|
||||
|
||||
So Zero does **not** call those backends directly or spoof those clients — that
|
||||
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
|
||||
The robust, supported pattern is a **local proxy** that holds your subscription
|
||||
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
|
||||
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
|
||||
just points at it.
|
||||
|
||||
### ChatGPT via a local proxy
|
||||
|
||||
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
|
||||
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
|
||||
preset (no API key — the proxy authenticates):
|
||||
|
||||
```jsonc
|
||||
// ~/.config/zero/config.json (or ./.zero/config.json)
|
||||
{
|
||||
"activeProvider": "chatgpt",
|
||||
"providers": [
|
||||
{
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
|
||||
```
|
||||
|
||||
### Claude via a local proxy
|
||||
|
||||
There is no single canonical Claude OAuth-proxy port, so use the generic
|
||||
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
|
||||
endpoint:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"activeProvider": "claude",
|
||||
"providers": [
|
||||
{
|
||||
"name": "claude",
|
||||
"catalogID": "custom-anthropic-compatible",
|
||||
"baseURL": "http://localhost:<port>", // your Claude proxy
|
||||
"apiKey": "unused-by-proxy",
|
||||
"model": "claude-sonnet-4.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported alternatives (no proxy)
|
||||
|
||||
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
|
||||
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
|
||||
Bills as API usage.
|
||||
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
|
||||
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
|
||||
first-class subscription session.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
|
||||
setup; they are not an endorsement of any specific proxy implementation.
|
||||
- Subscription-via-proxy depends on third-party tools and undocumented vendor
|
||||
backends; it can break without notice. The API-key path is the stable one.
|
||||
@@ -1,37 +1,36 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.38.0
|
||||
mvdan.cc/sh/v3 v3.11.0
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -1,31 +1,33 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -34,33 +36,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
|
||||
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -90,11 +90,48 @@ func toolFailureStopAnswer(toolName string, count int) string {
|
||||
"Please check the request or adjust the tool arguments."
|
||||
}
|
||||
|
||||
// The no-output stop answer is assembled from these fixed parts (only the turn
|
||||
// count varies). IsNoProgressStop matches all three so a legitimate message that
|
||||
// merely quotes the marker substring is not misclassified as a failed empty run.
|
||||
const (
|
||||
noOutputStopPrefix = "Agent stopped after "
|
||||
noOutputStopMarker = "with no output (no visible text and no tool calls)"
|
||||
noOutputStopSuffix = "to avoid consuming tokens without making progress."
|
||||
)
|
||||
|
||||
// noOutputStopAnswer is the final answer returned when the no-output guard
|
||||
// stops the run. The turn count is interpolated at the call site.
|
||||
func noOutputStopAnswer(turns int) string {
|
||||
return "Agent stopped after " + strconv.Itoa(turns) +
|
||||
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
|
||||
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
|
||||
}
|
||||
|
||||
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
|
||||
// (a run that produced no visible text and no tool calls). It matches the EXACT
|
||||
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
|
||||
// suffix, where only the integer turn count varies — rather than just looking for
|
||||
// the three parts in order. A loose check (prefix && contains-marker && suffix)
|
||||
// would misclassify a genuine assistant/tool message that merely quotes the
|
||||
// marker amid other prose, which would wrongly hide a real session from /resume
|
||||
// and skip its title generation.
|
||||
func IsNoProgressStop(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
|
||||
return false
|
||||
}
|
||||
rest := trimmed[len(noOutputStopPrefix):]
|
||||
const turnsSep = " turns "
|
||||
sep := strings.Index(rest, turnsSep)
|
||||
if sep < 0 {
|
||||
return false
|
||||
}
|
||||
// The text between the prefix and " turns " must be exactly the bare integer
|
||||
// count; anything else means this isn't the guard's own answer.
|
||||
if _, err := strconv.Atoi(rest[:sep]); err != nil {
|
||||
return false
|
||||
}
|
||||
// The marker must be immediately followed (one space) by the suffix and then
|
||||
// end — no arbitrary text wedged in between.
|
||||
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
|
||||
}
|
||||
|
||||
// Reminder markers are stable substrings used both to build the reminder text
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
|
||||
// The actual guardrail answer (any turn count) is recognized.
|
||||
for _, turns := range []int{1, 3, 12} {
|
||||
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
|
||||
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
|
||||
}
|
||||
}
|
||||
|
||||
// A legitimate message that merely QUOTES the marker must NOT be classified as
|
||||
// a no-progress stop — that would wrongly hide a real session from /resume.
|
||||
rejected := []string{
|
||||
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
|
||||
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
|
||||
"Agent stopped after 3 turns.", // prefix only
|
||||
"to avoid consuming tokens without making progress.", // suffix only
|
||||
"Here is your e-commerce site.", // unrelated
|
||||
"",
|
||||
// Hostile input: prefix + marker + suffix ALL present, but the marker is
|
||||
// quoted mid-sentence rather than being the guard's own structured answer.
|
||||
// A loose prefix/contains/suffix check misclassifies this.
|
||||
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
|
||||
// Arbitrary text wedged between the marker and the suffix.
|
||||
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
|
||||
// Non-integer "turn count" between the prefix and the marker.
|
||||
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
|
||||
}
|
||||
for _, content := range rejected {
|
||||
if IsNoProgressStop(content) {
|
||||
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,17 @@ work.
|
||||
managers). It is not a substitute for the file tools.
|
||||
- Treat tool output as ground truth. If a command fails, read the error, form a
|
||||
hypothesis, and address the root cause; do not retry the same call blindly.
|
||||
- When a web search or fetch tool is available, search the web before answering
|
||||
about an external entity, product, library, model, company, version, or recent
|
||||
release you do not recognize. Do not guess, and do not assume the most common
|
||||
meaning of an ambiguous name — use the conversation's domain (this is an AI /
|
||||
software tool) to disambiguate. If the first results look off-topic, refine the
|
||||
query and search again before answering; never reply that you don't know without
|
||||
searching first.
|
||||
- Do not web-search timeless facts, fundamentals, or anything answerable from the
|
||||
workspace — read the code with the file tools instead. Keep queries short (a few
|
||||
words), start broad then narrow, and scale the number of searches to the
|
||||
question: one for a single fact, a few for a deeper answer.
|
||||
|
||||
## Permission and safety
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"final response",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
|
||||
@@ -125,7 +125,9 @@ func Score(suite Suite, input ScoreInput) Report {
|
||||
if len(task.RequiredTraceEvents) > 0 {
|
||||
report.Results = append(report.Results, scoreTraceEvents(task.RequiredTraceEvents, input))
|
||||
}
|
||||
report.Results = append(report.Results, unknownCommandResults(input.CommandResults, seenCommands, input)...)
|
||||
for _, result := range unknownCommandResults(input.CommandResults, seenCommands, input) {
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
report.finishSummary()
|
||||
return report
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -515,7 +515,9 @@ func agentEvalReportFromBenchmark(suite agenteval.Suite, report agenteval.Benchm
|
||||
if task.Agent.Truncated {
|
||||
converted.Truncated = true
|
||||
}
|
||||
converted.Failures = append(converted.Failures, agentEvalFailuresFromTaskReport(task)...)
|
||||
for _, failure := range agentEvalFailuresFromTaskReport(task) {
|
||||
converted.Failures = append(converted.Failures, failure)
|
||||
}
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestDetectLocalRuntimesReportsReachableRuntime(t *testing.T) {
|
||||
if !detected[0].Reachable {
|
||||
t.Fatalf("runtime should be reachable: %#v", detected[0])
|
||||
}
|
||||
if len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
if detected[0].Models == nil || len(detected[0].Models) == 0 || detected[0].Models[0] != "llama3.1" {
|
||||
t.Fatalf("expected discovered model list, got %#v", detected[0].Models)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -302,7 +302,7 @@ func webFetchRedirectPolicy(previous func(*http.Request, []*http.Request) error,
|
||||
return fmt.Errorf("too many redirects: maximum is %d", webFetchRedirectLimit)
|
||||
}
|
||||
if err := validateParsedWebFetchURL(request.Context(), request.URL, resolver, gate); err != nil {
|
||||
return fmt.Errorf("unsafe redirect URL: %w", err)
|
||||
return fmt.Errorf("Unsafe redirect URL: %w", err)
|
||||
}
|
||||
if previous != nil {
|
||||
return previous(request, via)
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestWebFetchToolRejectsUnsafeRedirects(t *testing.T) {
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected redirect safety error, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "unsafe redirect URL") {
|
||||
if !strings.Contains(result.Output, "Unsafe redirect URL") {
|
||||
t.Fatalf("expected unsafe redirect message, got %q", result.Output)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(item))
|
||||
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
|
||||
}
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user