mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1705d3260e | ||
|
|
6df5c2b4cb | ||
|
|
b7f90a787f | ||
|
|
b1cf24831d | ||
|
|
0b496ce87d | ||
|
|
724167e90e | ||
|
|
6092303e73 | ||
|
|
1e25fde3b1 | ||
|
|
7fae5c530f | ||
|
|
7af2a1b3b6 | ||
|
|
9c1cc69b59 | ||
|
|
a0b9876b69 | ||
|
|
c38444157c | ||
|
|
75ecf8e385 | ||
|
|
efb6b79164 | ||
|
|
2bcb85bfb3 | ||
|
|
e1253c826c | ||
|
|
f857c4afb4 | ||
|
|
bb7de29c80 | ||
|
|
de5d402e1f | ||
|
|
9ddef0e957 | ||
|
|
ba8fbb8b71 | ||
|
|
265aa52ba7 | ||
|
|
135e3c5657 | ||
|
|
355b76cc78 | ||
|
|
6a9f44a716 | ||
|
|
e1f1b111a1 | ||
|
|
284a948f00 | ||
|
|
671af594fb | ||
|
|
7153ec7a7d | ||
|
|
73eb8d3376 | ||
|
|
0444eb0e6f |
No files matched your search
@@ -1,16 +1,311 @@
|
||||
---
|
||||
description: Use Go-native project commands; Node is only for the npm wrapper.
|
||||
description: Project-level instruction set for the AI agent working in this repo. Ten discrete modes covering the recurring engineering workflows on Zero.
|
||||
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to Go commands in this repository.
|
||||
# AGENTS.md
|
||||
|
||||
- Use `go run ./cmd/zero` to launch the local CLI.
|
||||
- Use `go test ./...` for the repository test suite.
|
||||
- Use `go run ./cmd/zero-release build` to build the release-facing binary.
|
||||
- Use `go run ./cmd/zero-release smoke` to smoke-test the built binary.
|
||||
- Use `go run ./cmd/zero-release package` and `go run ./cmd/zero-release verify` for release artifact checks.
|
||||
- Use `go run ./cmd/zero-perf-bench` for performance smoke work.
|
||||
- Do not add Bun or TypeScript build/test dependencies for repository validation.
|
||||
- Use Node only when directly exercising the npm wrapper at `bin/zero.js`.
|
||||
Instructions for the AI agent working inside the `zero` repository. Read this before any non-trivial change.
|
||||
|
||||
## 1. Repository map
|
||||
|
||||
### Entry points
|
||||
|
||||
- `cmd/zero` — primary CLI binary. Run with `go run ./cmd/zero`.
|
||||
- `cmd/zero-release` — release tooling. `build`, `smoke`, `package`, `verify`. Never bypass.
|
||||
- `cmd/zero-seccomp` — Linux seccomp profile generator for the sandbox.
|
||||
- `cmd/zero-perf-bench` — performance smoke work.
|
||||
- `cmd/zero-pr-review` — local PR-review runner.
|
||||
- `bin/zero.js` — npm wrapper around the Go binary. Touch only when adjusting the npm surface.
|
||||
|
||||
### Core internal packages
|
||||
|
||||
- `agent/` — the agent loop. Prompt in, tool calls out, results back, repeat. `guardrails.go` holds the loop's stop and confirmation thresholds. `loop.go` is the main state machine.
|
||||
- `tui/` — terminal UI built on `charm.land/bubbletea/v2` and `charm.land/lipgloss/v2`. `model.go` holds state, `view.go` is pure render, `command_center.go` dispatches slash commands, `theme.go` owns every color and border (hex literals are forbidden elsewhere).
|
||||
- `providers/` — LLM adapters. One subdir per provider: `openai/`, `anthropic/`, `gemini/`, `providerio/`. `factory.go` registers them.
|
||||
- `sandbox/` — exec gating and risk classification. `risk.go` defines patterns, `engine.go` decides allow/deny, `paths.go` and `pathlists.go` handle allow/deny lists.
|
||||
- `sessions/` — session state, persistence, resume, title generation.
|
||||
- `specialist/` — sub-agent routing. Specialists run scoped tasks with their own tool whitelist. `builtin.go` declares the interface, `runtime.go` runs them, `manifest.go` and `exec.go` are the execution paths.
|
||||
- `swarm/` — multi-agent coordination for batched work.
|
||||
- `mcp/` — Model Context Protocol client and tool bridge.
|
||||
- `modelregistry/` — model catalog, capability flags (vision, tool calls, reasoning efforts), cost data. `catalog.go` is `DefaultModelEntries`.
|
||||
- `providercatalog/` — provider descriptors (base URLs, auth env vars, default model).
|
||||
- `tools/` — agent-callable tools. Each tool implements the `Tool` interface in `tools/types.go`. `registry.go` is the central map; `CoreToolsScoped` / `CoreReadOnlyToolsScoped` / `CoreWriteToolsScoped` / `CoreShellToolsScoped` / `CoreNetworkTools` are the registration sites.
|
||||
- `cli/` — top-level command wiring. `app.go` is the entry, `command_center.go` dispatches subcommands, per-feature files (`exec.go`, `sessions.go`, `setup.go`, etc.) implement each subcommand.
|
||||
|
||||
Other packages worth knowing: `config/`, `redaction/`, `secrets/`, `imageinput/`, `cron/`, `doctor/`, `browser/`, `background/`, `lsp/`, `hooks/`, `update/`, `usage/`, `verify/`, `testrunner/`, `selfverify/`, `workspaceindex/`, `worktrees/`, `workspaceseed/`, `specmode/`, `streamjson/`, `notify/`, `keyring/`, `observability/`, `oauth/`, `provideroauth/`, `provideronboarding/`, `providerhealth/`, `providermodelcatalog/`, `providermodeldiscovery/`, `npmwrapper/`, `agenteval/`, `zerocommands/`, `zerogit/`, `zeroruntime/`, `release/`, `review/`, `repomap/`, `repoinfo/`, `search/`, `skills/`, `plugins/`.
|
||||
|
||||
### Test surface
|
||||
|
||||
- Tests live next to the file they cover (`foo.go` → `foo_test.go`). No separate `tests/` tree.
|
||||
- Snapshot tests use `charmbracelet/x/exp/golden` (visible in `go.sum`).
|
||||
- `go test ./internal/<pkg>/ -run TestX -v` for narrow runs; `go test ./...` for the full suite.
|
||||
- No `-race` on Windows (no gcc toolchain). On Linux/macOS, `-race` is fine.
|
||||
|
||||
## 2. Conventions
|
||||
|
||||
- Go 1.25, toolchain `go1.26.4`. Charm libs are pinned to v2 (`charm.land/bubbletea/v2`, `charm.land/lipgloss/v2`).
|
||||
- Shell parsing uses `mvdan.cc/sh/v3`, not `os/exec` + string concatenation. Never invent your own shell tokenizer.
|
||||
- `gofmt -l <files>` and `go vet ./...` must pass before commit. CI enforces both.
|
||||
- The npm wrapper at `bin/zero.js` is the only Node-touching surface; everything else is Go-native. Do not add Bun or TypeScript build deps.
|
||||
- Theme tokens are the single source of truth for color and border. Hex literals anywhere except `internal/tui/theme.go` are rejected.
|
||||
- The TUI split is strict: `model.go` owns state, `view.go` is pure render. Styling calls (`Style.Render`, `NewStyle`) live in `view.go` and render helpers under `tui/`; `model.go` may call `lipgloss.Width` for column measurements but never applies styles.
|
||||
- Errors are wrapped with `fmt.Errorf("...: %w", err)` and surface context. Never swallow errors silently.
|
||||
- Public API changes require a one-line note in the PR body explaining the impact and the migration path.
|
||||
|
||||
## 3. Ten agent modes
|
||||
|
||||
Each mode is a discrete workflow the AI agent adopts when the task matches. Pick the mode at the start of the task; do not blend them.
|
||||
|
||||
### Mode 1 — Add a slash command
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/commands.go` — add a `commandDefinition` entry. Set `name`, `aliases`, `usage`, `group`, `description`, `kind` (one of the `commandKind` constants in this file).
|
||||
- `internal/tui/command_center.go` — add a `case command<Name>:` branch to the slash-command dispatch. Match the dispatch shape used by existing commands: read `command.text`, mutate model state, append a transcript row.
|
||||
- `internal/tui/<feature>.go` — co-locate the handler with the related feature. Handler signature is `(model, args) (model, string)` where the string is the status text the transcript displays.
|
||||
- `internal/tui/view.go` or `internal/tui/command_output.go` — only if the command renders a card or panel; reuse the existing `commandCard` and `commandOutput` helpers.
|
||||
|
||||
Pattern to follow: copy `commandEffort` end-to-end (definition in `commands.go`, dispatch in `command_center.go`, handler in `session_controls.go`). Do not invent a new shape.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not register the command in two places. One definition, one dispatch, one handler.
|
||||
- Do not read input from `m.input.Value()` inside the handler. The dispatch already passes `command.text`.
|
||||
- Do not mutate `m.picker` from inside a slash-command handler. Use `m.openXxxPicker()` if a picker is needed.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go build ./cmd/zero
|
||||
go test ./internal/tui/ -run Test<Command> -v
|
||||
```
|
||||
|
||||
### Mode 2 — Add a tool
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tools/<name>.go` — implement the `Tool` interface from `internal/tools/types.go`. Required methods: `Name()`, `Description()`, `Parameters()`, `Safety()`, `Run(ctx, args) Result`. Fill `Safety{ SideEffect, Permission, Reason }` honestly.
|
||||
- `internal/tools/registry.go` — register the tool by adding it to the appropriate `CoreToolsScoped` variant (read-only, write, shell, network, or full). The factory functions decide which tool set a context gets.
|
||||
- `internal/tools/<name>_test.go` — table-driven test covering the happy path, each error path declared in the schema, and one permission-boundary case.
|
||||
|
||||
Pattern to follow: copy `internal/tools/read_file.go` end-to-end. It demonstrates the `Tool` interface, the schema pattern, and the test layout.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not call `os/exec` directly. The bash tool is the only path; everything else routes through `mvdan.cc/sh/v3` indirectly or uses Go stdlib.
|
||||
- Do not mutate `m.transcript` from inside a tool. Tools return `Result`; the agent loop appends to the transcript.
|
||||
- Do not skip `Safety`. Every tool must declare `SideEffect` and `Permission`.
|
||||
- Do not add new top-level deps. If a tool needs a library, justify it in the PR body.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/tools/ -run Test<Name> -v
|
||||
go vet ./internal/tools/
|
||||
```
|
||||
|
||||
### Mode 3 — Add a provider
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/providers/<name>/` — new subdir. Mirror `internal/providers/openai/` (factory wiring, request types, response mapping, image / tool-call handling, tests).
|
||||
- `internal/providers/factory.go` — register the new provider in the factory switch.
|
||||
- `internal/providercatalog/catalog.go` — add a `Descriptor` entry (ID, display name, default base URL, auth env vars, default model, capabilities).
|
||||
- `internal/cli/...` — only if the provider needs a new flag. Mirror existing flags before adding new ones.
|
||||
|
||||
Pattern to follow: copy `internal/providers/openai/`. It has factory wiring, request shaping, content-part conversion, image blocks, and tests for every code path.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not skip the image / vision path. Even if your first model doesn't support vision, the provider must handle `zeroruntime.ImageBlock` correctly (Anthropic `image` block, OpenAI `image_url`, Gemini `inlineData`).
|
||||
- Do not invent your own HTTP client. Reuse the one in `internal/providers/openai/` or `internal/providerio/`.
|
||||
- Do not hardcode model IDs. They live in `internal/modelregistry/`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/providers/<name>/ -v
|
||||
go test ./internal/providers/ -v
|
||||
go test ./internal/providercatalog/ -v
|
||||
```
|
||||
|
||||
### Mode 4 — Add a model entry
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/modelregistry/catalog.go` — extend `DefaultModelEntries()` with the new model. Fields: ID, Provider, DisplayName, ContextWindow, Capabilities (`vision`, `tool_call`, `reasoning`), ReasoningEfforts (if applicable), Cost.
|
||||
|
||||
Pattern to follow: open `catalog.go`, find the existing entry for the same provider, copy the row, edit the values. Same shape, same fields.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not invent capabilities. Set only the ones the model actually supports. If unsure, leave the capability unset.
|
||||
- Do not add cost data you cannot cite. If you don't have a source, leave the cost fields zero and note it in the PR.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/modelregistry/ -v
|
||||
go test ./internal/tui/ -run TestModel -v
|
||||
```
|
||||
|
||||
### Mode 5 — Add a specialist
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/specialist/<name>.go` — implement the specialist. Read `internal/specialist/builtin.go` for the interface and `runtime.go` for the execution model.
|
||||
- `internal/specialist/registry.go` — register the specialist. Update the routing table so the main agent knows when to dispatch to it.
|
||||
|
||||
Pattern to follow: copy `internal/specialist/exec.go` (or another existing specialist). The shape is consistent.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not give a specialist access to tools the main agent doesn't. Specialists have their own whitelist.
|
||||
- Do not let a specialist call another specialist. One level of dispatch only.
|
||||
- Do not register a specialist without a routing rule. Routing without a rule is unreachable code.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/specialist/ -v
|
||||
go test ./internal/agent/ -v
|
||||
```
|
||||
|
||||
### Mode 6 — Add a sandbox rule
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/sandbox/risk.go` — add a pattern to the appropriate risk bucket (`destructive`, `destructive_catastrophic`, `network`, `credential`, etc.).
|
||||
- `internal/sandbox/engine.go` — only if the rule needs a new decision branch.
|
||||
- `internal/sandbox/risk_hardening_test.go` (or a sibling test file) — write a failing test FIRST that exercises the new pattern.
|
||||
|
||||
Pattern to follow: read `risk.go` to see the existing pattern classification. The `add()` helper takes `(pattern, RiskLevel)`. New rules sit alongside their category.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not weaken an existing rule. If you find a false positive, write a more specific pattern that excludes the safe case rather than removing the dangerous one.
|
||||
- Do not add a rule without a test. Sandbox regressions are silent and dangerous.
|
||||
- Do not skip `requestPaths` checks. When a command takes paths, the sandbox must validate them against the workspace + grant scope.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/sandbox/ -run TestRisk -v
|
||||
```
|
||||
|
||||
### Mode 7 — Modify the TUI
|
||||
|
||||
Touch files:
|
||||
|
||||
- `internal/tui/model.go` — state only. Add fields, add cases to `Update()`, add dispatch logic. Never apply lipgloss styles here.
|
||||
- `internal/tui/view.go` — pure render. Read from `model`, render to styled strings. Every style application lives here or in a `render*` helper.
|
||||
- `internal/tui/<feature>.go` — split when the feature is large. Look at the existing per-feature files (`picker.go`, `session.go`, `transcript_body.go`, etc.) for the convention.
|
||||
- `internal/tui/theme.go` — only if you need a new color or border. Add a named token with a hex value; never inline a hex elsewhere.
|
||||
|
||||
Pattern to follow: open `model.go` and `view.go` side-by-side. Find the feature you're extending. The state mutation lives in `model.go`; the renderer lives in `view.go`. Tests live in `*_test.go` next to each.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not apply lipgloss styles from `model.go`.
|
||||
- Do not store styled strings in the model. Store raw values; render at draw time.
|
||||
- Do not bypass `m.transcript`. Every visible row goes through `appendTranscriptRow`.
|
||||
- Do not add a hex color literal anywhere except `theme.go`.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
gofmt -l internal/tui/
|
||||
go vet ./internal/tui/
|
||||
go test ./internal/tui/ -count=1
|
||||
```
|
||||
|
||||
### Mode 8 — Debug a failing test
|
||||
|
||||
Steps:
|
||||
|
||||
1. Run narrow: `go test ./internal/<pkg>/ -run Test<Name> -v -count=1`. Read the failure verbatim — the assertion message is the most reliable signal.
|
||||
2. Read the test. The test is the contract. Do not rewrite the test to match broken behavior.
|
||||
3. Read the production code under test. Find the divergence between what the test expects and what the code does.
|
||||
4. Fix the production code. If the production code is right and the test was wrong, fix the test — but only after proving the production behavior is correct.
|
||||
5. Run narrow again. Then run the package. Then run the adjacent packages. Then run `go test ./...` to catch regressions.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not `t.Skip()` to make a test pass. Skip with a TODO comment and a linked issue at most.
|
||||
- Do not delete a failing test without understanding why it was added.
|
||||
- Do not change a test's expected value without a commit message explaining why.
|
||||
- Do not modify `time.Now()` or any clock source to make a timing-sensitive test pass.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/<pkg>/ -run Test<Name> -v -count=1
|
||||
go test ./internal/<pkg>/ -count=1
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Mode 9 — Cut a release
|
||||
|
||||
Touch files:
|
||||
|
||||
- `cmd/zero-release` only. No manual `git tag`. No manual binary upload. No edits to `CHANGELOG.md` by hand.
|
||||
|
||||
Pattern to follow: run `go run ./cmd/zero-release build`, then `go run ./cmd/zero-release smoke`, then `go run ./cmd/zero-release package`, then `go run ./cmd/zero-release verify`. The release tooling is the contract.
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not push a tag by hand. The release tooling owns tag creation.
|
||||
- Do not skip `smoke`. If smoke fails, the release fails.
|
||||
- Do not edit `CHANGELOG.md` manually. The release tooling generates it from `git log` since the previous tag.
|
||||
- Do not bypass `verify`. Verification is the last gate.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
go run ./cmd/zero-release package
|
||||
go run ./cmd/zero-release verify
|
||||
```
|
||||
|
||||
### Mode 10 — Run a security or quality audit
|
||||
|
||||
Touch files (read-only by default; edits only after explicit user approval):
|
||||
|
||||
- `internal/sandbox/` — destructive-command classification in `risk.go`, decision logic in `engine.go`, path allow/deny in `paths.go` and `pathlists.go`. Audit coverage: do destructive paths actually get caught? Are grant scopes respected? Are `..` traversals blocked?
|
||||
- `internal/secrets/` — env handling. Audit: are secrets redacted in logs? Are `os.Getenv` calls going through the keyring when configured?
|
||||
- `internal/redaction/` — output redaction. Audit: is the redaction list current? Are there new tool outputs that need redaction entries?
|
||||
- `internal/providers/` — provider auth. Audit: are API keys read from env, never persisted? Are provider adapters validating their inputs?
|
||||
- `internal/mcp/` — MCP server trust. Audit: are MCP server outputs treated as data, never as instructions? Is the schema validated?
|
||||
- `internal/hooks/` — pre/post command hooks. Audit: do hooks have a way to abort? Is the abort path tested?
|
||||
- `docs/audit/` — read the most recent `*-deep-audit*.md` and `*-reverification.md` to see what's already been reviewed. Do not re-audit a category already covered.
|
||||
|
||||
Pattern to follow: pick one subsystem per audit pass. Read the code, run the existing tests, write a one-page report naming each finding with a `file:line` anchor and an exploitability classification (`exploitable | plausible | theoretical`).
|
||||
|
||||
What NOT to do:
|
||||
|
||||
- Do not audit and edit in the same pass. Audit first, get user sign-off, then fix.
|
||||
- Do not propose fixes that disable a security control to silence a false positive. Tighten the rule instead.
|
||||
- Do not skip the audit because a recent audit covered the area. Confirm the diff since the last audit is empty.
|
||||
- Do not write findings without a `file:line` anchor. Vague findings get deleted.
|
||||
|
||||
Verification:
|
||||
|
||||
```
|
||||
go test ./internal/sandbox/ -v
|
||||
go test ./internal/secrets/ -v
|
||||
go test ./internal/redaction/ -v
|
||||
go test ./internal/mcp/ -v
|
||||
```
|
||||
|
||||
## 4. Hard rules across all modes
|
||||
|
||||
- Never run `rm -rf`, `git reset --hard`, or force-push without an explicit user message.
|
||||
- Never edit a file you have not read in full first.
|
||||
- Never widen `Permission` from `prompt` to `allow` for a tool without a test that proves the wider permission is safe.
|
||||
- Never delete a test. The test is a contract.
|
||||
- Never bypass `go vet` or `gofmt` with a `// nolint:` comment unless the PR body explains why.
|
||||
- Never commit secrets, even temporarily. If a secret lands in a commit, revert the commit and rotate the secret.
|
||||
@@ -0,0 +1,184 @@
|
||||
# OAuth logins & using ChatGPT / Claude subscriptions
|
||||
|
||||
Zero supports two distinct things people mean by "log in with OAuth":
|
||||
|
||||
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
|
||||
fully built in (`zero auth login …`). Zero attaches the token to model calls
|
||||
automatically.
|
||||
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
|
||||
pay-per-token API key — only possible through a **local proxy**, for the
|
||||
reasons documented below. Zero ships a convenience preset and this recipe.
|
||||
|
||||
---
|
||||
|
||||
## 1. OAuth login for a provider or gateway (built in)
|
||||
|
||||
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
|
||||
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
|
||||
env vars and log in:
|
||||
|
||||
```sh
|
||||
export ZERO_OAUTH_ACME_CLIENT_ID=…
|
||||
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
|
||||
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
|
||||
export ZERO_OAUTH_ACME_SCOPES="openid profile"
|
||||
zero auth login acme # browser (loopback); --device for headless
|
||||
zero auth status
|
||||
```
|
||||
|
||||
When a login exists for a provider, the **OpenAI and Anthropic** providers send
|
||||
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
|
||||
`401`) instead of the API key. With no login they use the API key exactly as
|
||||
before. Tokens are stored 0600 (or the OS keyring with
|
||||
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
|
||||
|
||||
### In the setup wizard (`/provider`)
|
||||
|
||||
Running `/provider` opens a **"How do you want to connect?"** chooser:
|
||||
|
||||
```text
|
||||
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
|
||||
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
|
||||
```
|
||||
|
||||
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
|
||||
|
||||
```text
|
||||
❯ OpenRouter browser sign-in · creates a key
|
||||
xAI (Grok) browser or device code
|
||||
```
|
||||
|
||||
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
|
||||
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
|
||||
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
|
||||
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
|
||||
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
|
||||
**d** on the list to get a code to enter on another device instead of opening a
|
||||
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
|
||||
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
|
||||
equivalent is `zero auth login xai --device`.
|
||||
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
|
||||
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
|
||||
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
|
||||
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
|
||||
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
|
||||
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
|
||||
the provider profile and used normally.
|
||||
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
|
||||
ships a built-in preset for the public Grok-CLI client, but to keep third-party
|
||||
client identities out of the default credential path it is **off by default**.
|
||||
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
|
||||
(browser, or `--device` for headless) works one-click; the token is used directly
|
||||
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
|
||||
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
|
||||
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
|
||||
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
|
||||
Grok-CLI client that may change without notice.
|
||||
|
||||
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
|
||||
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
|
||||
`zero auth login <name>`.
|
||||
|
||||
---
|
||||
|
||||
## 2. ChatGPT / Claude subscriptions — why a proxy is required
|
||||
|
||||
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
|
||||
**not** work as a drop-in bearer against the standard APIs:
|
||||
|
||||
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
|
||||
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
|
||||
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
|
||||
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
|
||||
requires mimicking the official Codex client (originator + account-id header).
|
||||
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
|
||||
for third-party use unless the request spoofs the Claude Code identity
|
||||
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
|
||||
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
|
||||
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
|
||||
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
|
||||
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
|
||||
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
|
||||
then on **April 4 2026** enforcement landed and subscription OAuth tokens
|
||||
**stopped working in third-party harnesses** (starting with OpenClaw, then the
|
||||
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
|
||||
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
|
||||
both per-token, not the flat subscription. The request to allow subscription use
|
||||
(claude-code #37205) was closed *"not planned."*
|
||||
|
||||
So Zero does **not** call those backends directly or spoof those clients — that
|
||||
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
|
||||
The robust, supported pattern is a **local proxy** that holds your subscription
|
||||
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
|
||||
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
|
||||
just points at it.
|
||||
|
||||
### ChatGPT via a local proxy
|
||||
|
||||
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
|
||||
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
|
||||
preset (no API key — the proxy authenticates):
|
||||
|
||||
```jsonc
|
||||
// ~/.config/zero/config.json (or ./.zero/config.json)
|
||||
{
|
||||
"activeProvider": "chatgpt",
|
||||
"providers": [
|
||||
{
|
||||
"name": "chatgpt",
|
||||
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
|
||||
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
|
||||
"model": "gpt-5" // whatever model your proxy serves
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
|
||||
```
|
||||
|
||||
### Claude via a local proxy
|
||||
|
||||
There is no single canonical Claude OAuth-proxy port, so use the generic
|
||||
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
|
||||
endpoint:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"activeProvider": "claude",
|
||||
"providers": [
|
||||
{
|
||||
"name": "claude",
|
||||
"catalogID": "custom-anthropic-compatible",
|
||||
"baseURL": "http://localhost:<port>", // your Claude proxy
|
||||
"apiKey": "unused-by-proxy",
|
||||
"model": "claude-sonnet-4.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported alternatives (no proxy)
|
||||
|
||||
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
|
||||
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
|
||||
Bills as API usage.
|
||||
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
|
||||
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
|
||||
first-class subscription session.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
|
||||
setup; they are not an endorsement of any specific proxy implementation.
|
||||
- Subscription-via-proxy depends on third-party tools and undocumented vendor
|
||||
backends; it can break without notice. The API-key path is the stable one.
|
||||
@@ -1,37 +1,36 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.38.0
|
||||
mvdan.cc/sh/v3 v3.11.0
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -1,31 +1,33 @@
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -34,33 +36,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
|
||||
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
@@ -90,11 +90,48 @@ func toolFailureStopAnswer(toolName string, count int) string {
|
||||
"Please check the request or adjust the tool arguments."
|
||||
}
|
||||
|
||||
// The no-output stop answer is assembled from these fixed parts (only the turn
|
||||
// count varies). IsNoProgressStop matches all three so a legitimate message that
|
||||
// merely quotes the marker substring is not misclassified as a failed empty run.
|
||||
const (
|
||||
noOutputStopPrefix = "Agent stopped after "
|
||||
noOutputStopMarker = "with no output (no visible text and no tool calls)"
|
||||
noOutputStopSuffix = "to avoid consuming tokens without making progress."
|
||||
)
|
||||
|
||||
// noOutputStopAnswer is the final answer returned when the no-output guard
|
||||
// stops the run. The turn count is interpolated at the call site.
|
||||
func noOutputStopAnswer(turns int) string {
|
||||
return "Agent stopped after " + strconv.Itoa(turns) +
|
||||
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
|
||||
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
|
||||
}
|
||||
|
||||
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
|
||||
// (a run that produced no visible text and no tool calls). It matches the EXACT
|
||||
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
|
||||
// suffix, where only the integer turn count varies — rather than just looking for
|
||||
// the three parts in order. A loose check (prefix && contains-marker && suffix)
|
||||
// would misclassify a genuine assistant/tool message that merely quotes the
|
||||
// marker amid other prose, which would wrongly hide a real session from /resume
|
||||
// and skip its title generation.
|
||||
func IsNoProgressStop(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
|
||||
return false
|
||||
}
|
||||
rest := trimmed[len(noOutputStopPrefix):]
|
||||
const turnsSep = " turns "
|
||||
sep := strings.Index(rest, turnsSep)
|
||||
if sep < 0 {
|
||||
return false
|
||||
}
|
||||
// The text between the prefix and " turns " must be exactly the bare integer
|
||||
// count; anything else means this isn't the guard's own answer.
|
||||
if _, err := strconv.Atoi(rest[:sep]); err != nil {
|
||||
return false
|
||||
}
|
||||
// The marker must be immediately followed (one space) by the suffix and then
|
||||
// end — no arbitrary text wedged in between.
|
||||
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
|
||||
}
|
||||
|
||||
// Reminder markers are stable substrings used both to build the reminder text
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
|
||||
// The actual guardrail answer (any turn count) is recognized.
|
||||
for _, turns := range []int{1, 3, 12} {
|
||||
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
|
||||
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
|
||||
}
|
||||
}
|
||||
|
||||
// A legitimate message that merely QUOTES the marker must NOT be classified as
|
||||
// a no-progress stop — that would wrongly hide a real session from /resume.
|
||||
rejected := []string{
|
||||
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
|
||||
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
|
||||
"Agent stopped after 3 turns.", // prefix only
|
||||
"to avoid consuming tokens without making progress.", // suffix only
|
||||
"Here is your e-commerce site.", // unrelated
|
||||
"",
|
||||
// Hostile input: prefix + marker + suffix ALL present, but the marker is
|
||||
// quoted mid-sentence rather than being the guard's own structured answer.
|
||||
// A loose prefix/contains/suffix check misclassifies this.
|
||||
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
|
||||
// Arbitrary text wedged between the marker and the suffix.
|
||||
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
|
||||
// Non-integer "turn count" between the prefix and the marker.
|
||||
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
|
||||
}
|
||||
for _, content := range rejected {
|
||||
if IsNoProgressStop(content) {
|
||||
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,17 @@ work.
|
||||
managers). It is not a substitute for the file tools.
|
||||
- Treat tool output as ground truth. If a command fails, read the error, form a
|
||||
hypothesis, and address the root cause; do not retry the same call blindly.
|
||||
- When a web search or fetch tool is available, search the web before answering
|
||||
about an external entity, product, library, model, company, version, or recent
|
||||
release you do not recognize. Do not guess, and do not assume the most common
|
||||
meaning of an ambiguous name — use the conversation's domain (this is an AI /
|
||||
software tool) to disambiguate. If the first results look off-topic, refine the
|
||||
query and search again before answering; never reply that you don't know without
|
||||
searching first.
|
||||
- Do not web-search timeless facts, fundamentals, or anything answerable from the
|
||||
workspace — read the code with the file tools instead. Keep queries short (a few
|
||||
words), start broad then narrow, and scale the number of searches to the
|
||||
question: one for a single fact, a few for a deeper answer.
|
||||
|
||||
## Permission and safety
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
"search the web before answering",
|
||||
"do not recognize",
|
||||
"final response",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
|
||||
@@ -186,10 +186,6 @@ func validateFileList(taskPath string, field string, files []string, required bo
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateExpectedChangedFiles(taskPath string, files []string) []string {
|
||||
return validateFileList(taskPath, "expectedChangedFiles", files, true)
|
||||
}
|
||||
|
||||
func validateStringList(taskPath string, field string, values []string) []string {
|
||||
problems := []string{}
|
||||
seen := map[string]int{}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package browser opens a URL in the user's default web browser. It is used by
|
||||
// interactive flows (e.g. the provider setup wizard's OAuth login) that need to
|
||||
// hand the user off to a browser for authorization.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// OpenURL launches url in the default browser. It returns once the launcher
|
||||
// process has started (it does not wait for the browser to close). A failure to
|
||||
// start the launcher is returned so callers can fall back to printing the URL.
|
||||
func OpenURL(url string) error {
|
||||
name, args := openCommand(runtime.GOOS, url)
|
||||
cmd := exec.Command(name, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("browser: open url: %w", err)
|
||||
}
|
||||
// Reap the short-lived launcher (open/xdg-open/rundll32 exit promptly) so it
|
||||
// does not linger as a zombie; the browser window itself is independent.
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// openCommand returns the launcher command + args for a platform. Split out as a
|
||||
// pure function so the per-OS choice is unit-testable without spawning anything.
|
||||
func openCommand(goos, url string) (string, []string) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "open", []string{url}
|
||||
case "windows":
|
||||
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||
default: // linux and other unixes
|
||||
return "xdg-open", []string{url}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
goos string
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"darwin", "open", []string{"https://x.test"}},
|
||||
{"linux", "xdg-open", []string{"https://x.test"}},
|
||||
{"freebsd", "xdg-open", []string{"https://x.test"}},
|
||||
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x.test"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.goos, func(t *testing.T) {
|
||||
name, args := openCommand(tc.goos, "https://x.test")
|
||||
if name != tc.name || !reflect.DeepEqual(args, tc.args) {
|
||||
t.Fatalf("openCommand(%s) = %q %v, want %q %v", tc.goos, name, args, tc.name, tc.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+83
-20
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/skills"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/swarm"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/update"
|
||||
@@ -103,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 {
|
||||
@@ -240,6 +245,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
case "exec":
|
||||
// Forward leading --add-dir occurrences so exec's own parser collects them.
|
||||
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
|
||||
case "daemon":
|
||||
return runDaemon(args[1:], stdout, stderr, deps)
|
||||
case "config":
|
||||
return runConfig(args[1:], stdout, stderr, deps)
|
||||
case "models":
|
||||
@@ -274,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":
|
||||
@@ -415,8 +424,26 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
|
||||
}
|
||||
doctorUserConfigPath := ""
|
||||
projectConfigPath := ""
|
||||
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
|
||||
doctorUserConfigPath = resolveOptions.UserConfigPath
|
||||
projectConfigPath = resolveOptions.ProjectConfigPath
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -507,20 +534,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
})
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
Cwd: workspaceRoot,
|
||||
UserConfigPath: userConfigPath,
|
||||
DoctorUserConfigPath: doctorUserConfigPath,
|
||||
ProjectConfigPath: projectConfigPath,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelName: resolved.Provider.Model,
|
||||
ProviderProfile: resolved.Provider,
|
||||
FavoriteModels: resolved.Preferences.FavoriteModels,
|
||||
Provider: provider,
|
||||
NewProvider: deps.newProvider,
|
||||
ProbeProviderHealth: deps.probeProviderHealth,
|
||||
UserAgent: userAgent(),
|
||||
Registry: registry,
|
||||
SessionStore: deps.newSessionStore(),
|
||||
SandboxStore: sandboxStore,
|
||||
MCPConfig: mcpConfig,
|
||||
MCPPermissionStore: mcpPermissionStore,
|
||||
MCPTokenStore: mcpTokenStore,
|
||||
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -582,12 +613,37 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
|
||||
return registry
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
|
||||
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
|
||||
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
|
||||
// both, closeSpecialistRuntime tears down both.
|
||||
type agentToolRuntime struct {
|
||||
specialist *specialist.Runtime
|
||||
swarm *swarm.Swarm
|
||||
}
|
||||
|
||||
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
|
||||
paths, err := specialist.DefaultPaths(workspaceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
|
||||
executor := specialist.Executor{Paths: paths}
|
||||
runtime, err := specialist.RegisterTools(registry, executor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The swarm reuses the same specialist executor to launch each member, so
|
||||
// every member runs under the orchestrator's sandbox + policy. Mailbox state
|
||||
// lives under the workspace so its files fall within the sandbox write rules.
|
||||
sw, err := swarm.New(swarm.Options{
|
||||
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
|
||||
Launcher: swarm.NewSpecialistLauncher(executor),
|
||||
})
|
||||
if err != nil {
|
||||
runtime.Close()
|
||||
return nil, err
|
||||
}
|
||||
swarm.RegisterTools(registry, sw)
|
||||
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
|
||||
}
|
||||
|
||||
func shouldRegisterExecSpecialistTools(options execOptions) bool {
|
||||
@@ -609,12 +665,17 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
|
||||
}
|
||||
}
|
||||
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
|
||||
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
if err := runtime.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
if runtime.swarm != nil {
|
||||
runtime.swarm.Close()
|
||||
}
|
||||
if runtime.specialist != nil {
|
||||
if err := runtime.specialist.Close(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +698,7 @@ Usage:
|
||||
|
||||
Commands:
|
||||
exec Run a one-shot prompt through the Go agent runtime
|
||||
daemon Manage the local background worker daemon (start/stop/status/run/attach)
|
||||
setup Guide first-run provider setup
|
||||
config Inspect resolved Go configuration without leaking secrets
|
||||
models List Zero model registry entries
|
||||
@@ -655,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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -59,10 +60,38 @@ func TestRunPrintsHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func setCLIUserConfigRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("APPDATA", root)
|
||||
case "darwin":
|
||||
t.Setenv("HOME", root)
|
||||
default:
|
||||
t.Setenv("XDG_CONFIG_HOME", root)
|
||||
}
|
||||
|
||||
configRoot, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserConfigDir() error = %v", err)
|
||||
}
|
||||
return configRoot
|
||||
}
|
||||
|
||||
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cwd := t.TempDir()
|
||||
setCLIUserConfigRoot(t)
|
||||
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
|
||||
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
|
||||
t.Fatalf("create project config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
||||
var launchedOptions tui.Options
|
||||
|
||||
@@ -104,6 +133,12 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
|
||||
if launchedOptions.UserConfigPath != userConfigPath {
|
||||
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
||||
}
|
||||
if launchedOptions.DoctorUserConfigPath != "" {
|
||||
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
|
||||
}
|
||||
if launchedOptions.ProjectConfigPath != projectConfigPath {
|
||||
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
|
||||
}
|
||||
if launchedOptions.Provider != nil {
|
||||
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ type backendStatusOptions struct {
|
||||
}
|
||||
|
||||
func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "doctor":
|
||||
return runBackendsDoctor(args[1:], stdout, stderr, deps)
|
||||
}
|
||||
}
|
||||
|
||||
options, help, err := parseBackendsArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
@@ -44,6 +51,44 @@ func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
type backendDoctorOptions struct {
|
||||
json bool
|
||||
}
|
||||
|
||||
func runBackendsDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
||||
options, help, err := parseBackendsDoctorArgs(args)
|
||||
if err != nil {
|
||||
return writeExecUsageError(stderr, err.Error())
|
||||
}
|
||||
if help {
|
||||
if err := writeBackendsDoctorHelp(stdout); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
report, err := backendDoctorReport(deps)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
||||
}
|
||||
if options.json {
|
||||
if err := writePrettyJSON(stdout, report); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatBackendDoctorReport(report), redaction.Options{})); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if !report.OK {
|
||||
return exitProvider
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
options := backendStatusOptions{}
|
||||
for _, arg := range args {
|
||||
@@ -59,6 +104,21 @@ func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func parseBackendsDoctorArgs(args []string) (backendDoctorOptions, bool, error) {
|
||||
options := backendDoctorOptions{}
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help", "help":
|
||||
return options, true, nil
|
||||
case "--json":
|
||||
options.json = true
|
||||
default:
|
||||
return options, false, execUsageError{fmt.Sprintf("unknown backends doctor flag %q", arg)}
|
||||
}
|
||||
}
|
||||
return options, false, nil
|
||||
}
|
||||
|
||||
func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapshot, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
@@ -86,6 +146,32 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
|
||||
return zerocommands.NewBackendLifecycleSnapshot(servers, hookResult.Config.Hooks, pluginResult.Plugins), nil
|
||||
}
|
||||
|
||||
func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error) {
|
||||
cwd, err := deps.getwd()
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := deps.resolveMCPConfig(cwd)
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
hookResult, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
pluginResult, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
|
||||
if err != nil {
|
||||
return zerocommands.BackendDoctorReport{}, err
|
||||
}
|
||||
|
||||
return zerocommands.NewBackendDoctorReport(zerocommands.BackendDoctorInput{
|
||||
MCP: cfg,
|
||||
Hooks: hookResult,
|
||||
Plugins: pluginResult,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapshot) string {
|
||||
lines := []string{"Zero Backends:"}
|
||||
lines = append(lines, fmt.Sprintf(" MCP servers: %d", len(snapshot.MCPServers)))
|
||||
@@ -135,16 +221,52 @@ func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapsh
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatBackendDoctorReport(report zerocommands.BackendDoctorReport) string {
|
||||
lines := []string{
|
||||
"Zero backend doctor",
|
||||
"Overall: " + string(report.Status),
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
target := ""
|
||||
if check.Target != "" {
|
||||
target = " " + check.Target
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[%s] %s%s - %s", check.Status, check.ID, target, check.Message))
|
||||
if check.Action != "" {
|
||||
lines = append(lines, " action: "+check.Action)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeBackendsHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends [flags]
|
||||
zero backends doctor [flags]
|
||||
|
||||
Inspect MCP, hook, and plugin backend lifecycle state without connecting to
|
||||
external MCP servers.
|
||||
|
||||
Commands:
|
||||
doctor Diagnose MCP, hook, and plugin backend setup
|
||||
|
||||
Flags:
|
||||
--json Print backend lifecycle data as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBackendsDoctorHelp(w io.Writer) error {
|
||||
_, err := fmt.Fprint(w, `Usage:
|
||||
zero backends doctor [flags]
|
||||
|
||||
Diagnose MCP, hook, and plugin backend setup without connecting to external MCP
|
||||
servers or executing hooks/plugins.
|
||||
|
||||
Flags:
|
||||
--json Print backend diagnostics as JSON
|
||||
-h, --help Show this help
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -153,3 +158,220 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
secret := "sk-proj-" + strings.Repeat("b", 24)
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
|
||||
if workspaceRoot != cwd {
|
||||
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
||||
}
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {
|
||||
Type: "http",
|
||||
URL: "https://api.example.com/mcp?token=" + secret,
|
||||
},
|
||||
"broken": {
|
||||
Type: "http",
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("hook Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return hooks.LoadResult{Diagnostics: []hooks.Diagnostic{{
|
||||
Kind: hooks.DiagnosticSchema,
|
||||
Message: "schema failed " + secret,
|
||||
HookID: "zero.bad",
|
||||
}}}, nil
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
if options.Cwd != cwd {
|
||||
t.Fatalf("plugin Cwd = %q, want %q", options.Cwd, cwd)
|
||||
}
|
||||
return plugins.LoadResult{Plugins: []plugins.LoadedPlugin{{
|
||||
ID: "zero.docs",
|
||||
Name: "Docs",
|
||||
Enabled: true,
|
||||
Source: plugins.SourceProject,
|
||||
}}}, nil
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not connect to MCP servers")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("exitCode = %d, want %d stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), secret) || strings.Contains(stdout.String(), "sk-proj-") {
|
||||
t.Fatalf("backend doctor JSON leaked secret material:\n%s", stdout.String())
|
||||
}
|
||||
var payload zerocommands.BackendDoctorReport
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("backend doctor JSON failed to decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.OK {
|
||||
t.Fatalf("payload.OK = true, want false for invalid MCP/hook diagnostic: %#v", payload.Checks)
|
||||
}
|
||||
if payload.Status != zerocommands.BackendDoctorStatusFail {
|
||||
t.Fatalf("payload.Status = %q, want %q", payload.Status, zerocommands.BackendDoctorStatusFail)
|
||||
}
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.mcp.invalid", "broken")
|
||||
assertBackendDoctorPayloadCheck(t, payload, "backend.hooks.diagnostic", "zero.bad")
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
exitCode = runWithDeps([]string{"backends", "doctor"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitProvider {
|
||||
t.Fatalf("text exitCode = %d, want %d stderr=%s", exitCode, exitProvider, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Zero backend doctor", "[fail] backend.mcp.invalid", "zero mcp add broken", "[fail] backend.hooks.diagnostic"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor text missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorHelp(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--help"}, &stdout, &stderr, appDeps{})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, want := range []string{"Usage:", "zero backends doctor", "--json"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("backend doctor help missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
hookSentinel := filepath.Join(cwd, "hook-ran")
|
||||
pluginToolSentinel := filepath.Join(cwd, "plugin-tool-ran")
|
||||
pluginHookSentinel := filepath.Join(cwd, "plugin-hook-ran")
|
||||
helperCommand := os.Args[0]
|
||||
helperArgs := func(path string) []string {
|
||||
return []string{"-test.run=TestBackendDoctorHelperProcess", "--", "--zero-backend-doctor-sentinel", path}
|
||||
}
|
||||
|
||||
var hits int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
hookConfigPath := filepath.Join(cwd, ".zero", "hooks.json")
|
||||
writeBackendDoctorJSON(t, hookConfigPath, map[string]any{
|
||||
"enabled": true,
|
||||
"hooks": []any{map[string]any{
|
||||
"id": "zero.sentinel",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(hookSentinel),
|
||||
}},
|
||||
})
|
||||
pluginDir := filepath.Join(cwd, ".zero", "plugins", "sentinel")
|
||||
writeBackendDoctorJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{
|
||||
"schemaVersion": 1,
|
||||
"id": "zero.sentinel",
|
||||
"name": "Sentinel",
|
||||
"version": "1.0.0",
|
||||
"tools": []any{map[string]any{
|
||||
"name": "sentinel_tool",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginToolSentinel),
|
||||
}},
|
||||
"hooks": []any{map[string]any{
|
||||
"name": "sentinel_hook",
|
||||
"event": "sessionStart",
|
||||
"command": helperCommand,
|
||||
"args": helperArgs(pluginHookSentinel),
|
||||
}},
|
||||
})
|
||||
|
||||
deps := appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"remote": {Type: "http", URL: server.URL + "/mcp"},
|
||||
}}, nil
|
||||
},
|
||||
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
|
||||
return hooks.LoadConfig(hooks.LoadOptions{
|
||||
Cwd: options.Cwd,
|
||||
UserConfigPath: filepath.Join(cwd, "missing-user-hooks.json"),
|
||||
ProjectConfigPath: hookConfigPath,
|
||||
})
|
||||
},
|
||||
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
|
||||
return plugins.Load(plugins.LoadOptions{
|
||||
Roots: []plugins.Root{{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")}},
|
||||
Cwd: options.Cwd,
|
||||
})
|
||||
},
|
||||
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return nil, errors.New("zero backends doctor must not register MCP tools")
|
||||
},
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 0 {
|
||||
t.Fatalf("backend doctor connected to MCP HTTP server %d time(s)", got)
|
||||
}
|
||||
for _, sentinel := range []string{hookSentinel, pluginToolSentinel, pluginHookSentinel} {
|
||||
if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backend doctor executed configured backend command, sentinel=%s err=%v", sentinel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackendDoctorPayloadCheck(t *testing.T, report zerocommands.BackendDoctorReport, id string, target string) {
|
||||
t.Helper()
|
||||
for _, check := range report.Checks {
|
||||
if check.ID == id && check.Target == target {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %s/%s not found in %#v", id, target, report.Checks)
|
||||
}
|
||||
|
||||
func writeBackendDoctorJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("create dir for %s: %v", path, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendDoctorHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg != "--zero-backend-doctor-sentinel" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(os.Args[index+1], []byte("executed"), 0o600); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// pool of headless `zero exec` workers and routes sessions to them over an
|
||||
// owner-only local control socket. It is an ADDITIVE surface — interactive and
|
||||
// one-shot exec are unchanged.
|
||||
func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
|
||||
if len(args) == 0 {
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
switch sub {
|
||||
case "start":
|
||||
return runDaemonStart(rest, stdout, stderr)
|
||||
case "stop":
|
||||
return runDaemonStop(rest, stdout, stderr)
|
||||
case "status":
|
||||
return runDaemonStatus(rest, stdout, stderr)
|
||||
case "run":
|
||||
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:
|
||||
fmt.Fprintf(stderr, "unknown daemon subcommand %q\n", sub)
|
||||
return writeDaemonUsage(stderr, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDaemonUsage(w io.Writer, code int) int {
|
||||
fmt.Fprint(w, `Usage: zero daemon <command>
|
||||
|
||||
Commands:
|
||||
start [--foreground] Start the daemon (background by default).
|
||||
stop Gracefully stop the running daemon.
|
||||
status Show daemon / pool / session status.
|
||||
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
|
||||
}
|
||||
|
||||
// runDaemonStart starts the daemon. Without --foreground it spawns a detached
|
||||
// background process running the foreground daemon and returns once it is up.
|
||||
func runDaemonStart(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
foreground := false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--foreground", "-f":
|
||||
foreground = true
|
||||
case "-h", "--help":
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
default:
|
||||
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon start", a))
|
||||
}
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if foreground {
|
||||
return runDaemonForeground(paths, stdout, stderr)
|
||||
}
|
||||
return runDaemonStartDetached(paths, stdout, stderr)
|
||||
}
|
||||
|
||||
// runDaemonForeground runs the daemon in this process until SIGINT/SIGTERM. Each
|
||||
// worker is a headless `zero exec` child with the sandbox re-entrancy markers
|
||||
// scrubbed (NewExecLauncher) so it establishes its own sandbox.
|
||||
func runDaemonForeground(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
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)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
srv.Shutdown()
|
||||
}()
|
||||
|
||||
if err := srv.Serve(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// runDaemonStartDetached spawns the foreground daemon as a detached background
|
||||
// process (its own process group, output to a log file) and waits for it to bind.
|
||||
func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintln(stdout, "zero daemon is already running")
|
||||
return exitSuccess
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
cmd := exec.Command(exe, "daemon", "start", "--foreground")
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
|
||||
if err := cmd.Start(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
_ = cmd.Process.Release()
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if daemonReachable(paths) {
|
||||
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
|
||||
return exitSuccess
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
|
||||
}
|
||||
|
||||
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon stop takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Shutdown(); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintln(stdout, "zero daemon stopped")
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
if helpRequested(args) {
|
||||
return writeDaemonUsage(stdout, exitSuccess)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return writeExecUsageError(stderr, "daemon status takes no arguments")
|
||||
}
|
||||
paths, err := daemon.DefaultPaths()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stdout, "zero daemon is not running")
|
||||
return exitSuccess
|
||||
}
|
||||
defer client.Close()
|
||||
report, err := client.Status()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
fmt.Fprintf(stdout, "daemon pid=%d version=%d socket=%s\n", report.PID, report.Version, report.Socket)
|
||||
fmt.Fprintf(stdout, "pool size=%d busy=%d queue=%d\n", report.PoolSize, len(report.Workers), report.QueueDepth)
|
||||
for _, s := range report.Sessions {
|
||||
fmt.Fprintf(stdout, " session %s state=%s lines=%d\n", s.ID, s.State, s.Lines)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
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]
|
||||
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 == "--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 {
|
||||
return writeExecUsageError(stderr, "--session requires a value")
|
||||
}
|
||||
session = v
|
||||
case strings.HasPrefix(a, "--session="):
|
||||
session = strings.TrimPrefix(a, "--session=")
|
||||
case a == "--cwd":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--cwd requires a value")
|
||||
}
|
||||
cwd = v
|
||||
case strings.HasPrefix(a, "--cwd="):
|
||||
cwd = strings.TrimPrefix(a, "--cwd=")
|
||||
case a == "--prompt" || a == "-p":
|
||||
v, ok := value()
|
||||
if !ok {
|
||||
return writeExecUsageError(stderr, "--prompt requires a value")
|
||||
}
|
||||
prompt = v
|
||||
case strings.HasPrefix(a, "--prompt="):
|
||||
prompt = strings.TrimPrefix(a, "--prompt=")
|
||||
default:
|
||||
// Forwarded verbatim to the worker `zero exec` (reuses exec's own flag
|
||||
// parsing for per-session run options).
|
||||
forward = append(forward, a)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon run requires --session <id>")
|
||||
}
|
||||
if prompt == "" && len(forward) == 0 {
|
||||
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
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 {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
|
||||
session := ""
|
||||
var remoteFlags remoteDialFlags
|
||||
extra := 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 == "--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
|
||||
default:
|
||||
extra++
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(session) == "" {
|
||||
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
|
||||
}
|
||||
if extra > 0 {
|
||||
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
|
||||
}
|
||||
client, err := dialForCLI(remoteFlags)
|
||||
if err != nil {
|
||||
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
|
||||
return writeAppError(stderr, err.Error(), exitCrash)
|
||||
}
|
||||
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 {
|
||||
client, err := daemon.Dial(paths.Socket)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = client.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func helpRequested(args []string) bool {
|
||||
for _, a := range args {
|
||||
if a == "-h" || a == "--help" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
||||
// a real daemon on the dev machine.
|
||||
func isolateDaemonPaths(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
var out, errb bytes.Buffer
|
||||
code := runDaemon(args, &out, &errb, appDeps{})
|
||||
return code, out.String(), errb.String()
|
||||
}
|
||||
|
||||
func TestDaemonUsage(t *testing.T) {
|
||||
code, _, _ := runDaemonCLI(t)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("no-args exit = %d, want exitUsage", code)
|
||||
}
|
||||
code, out, _ := runDaemonCLI(t, "--help")
|
||||
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
|
||||
t.Fatalf("--help exit=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonUnknownSubcommand(t *testing.T) {
|
||||
code, _, errb := runDaemonCLI(t, "frobnicate")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "unknown daemon subcommand") {
|
||||
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without --session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--session") {
|
||||
t.Fatalf("stderr = %q, want a --session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "--prompt") {
|
||||
t.Fatalf("stderr = %q, want a --prompt hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStopWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "stop")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("stop output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonStatusWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, out, _ := runDaemonCLI(t, "status")
|
||||
if code != exitSuccess {
|
||||
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
|
||||
}
|
||||
if !strings.Contains(out, "not running") {
|
||||
t.Fatalf("status output = %q, want 'not running'", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAttachRequiresSession(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "attach")
|
||||
if code != exitUsage {
|
||||
t.Fatalf("attach without session exit = %d, want exitUsage", code)
|
||||
}
|
||||
if !strings.Contains(errb, "session") {
|
||||
t.Fatalf("stderr = %q, want a session hint", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRunWhenNotRunning(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
|
||||
if code != exitCrash {
|
||||
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
|
||||
}
|
||||
if !strings.Contains(errb, "not running") {
|
||||
t.Fatalf("stderr = %q, want 'not running'", errb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
||||
isolateDaemonPaths(t)
|
||||
cases := [][]string{
|
||||
{"stop", "oops"},
|
||||
{"status", "oops"},
|
||||
{"attach", "s1", "extra"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
code, _, errb := runDaemonCLI(t, args...)
|
||||
if code != exitUsage {
|
||||
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -163,7 +162,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if options.allowEscalation {
|
||||
registry.Register(tools.NewEscalateModelTool())
|
||||
}
|
||||
var specialistRuntime *specialist.Runtime
|
||||
var specialistRuntime *agentToolRuntime
|
||||
if shouldRegisterExecSpecialistTools(options) {
|
||||
var err error
|
||||
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
|
||||
@@ -368,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,
|
||||
@@ -704,7 +709,8 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
|
||||
// depth — it guarantees that even a direct/programmatic caller passing a typo
|
||||
// can never WIDEN the ceiling back to the High default.
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy: the autonomy ceiling and the network mode.
|
||||
// the default policy (the autonomy ceiling and the network mode), plus the
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
@@ -721,6 +727,12 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
|
||||
if cfg.MonitorDenials {
|
||||
policy.MonitorDenials = true
|
||||
}
|
||||
// ZERO_SANDBOX_AUTO_ALLOW_BASH opts into auto-allowing a *sandboxed* shell
|
||||
// command without a prompt — the active sandbox is the safety boundary, and
|
||||
// the engine only honors this when the shell sandbox is actually active. Like
|
||||
// the flags above it only ever turns the opt-in ON; unset/blank stays off
|
||||
// (prompt), and an explicit config opt-in is preserved.
|
||||
policy = sandbox.ApplyAutoAllowBashEnv(policy)
|
||||
return policy
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -662,6 +662,19 @@ func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyHonorsAutoAllowBashEnv(t *testing.T) {
|
||||
// Unset/blank: the opt-in stays off so a sandboxed bash command still prompts.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("auto-allow-bash must be off when ZERO_SANDBOX_AUTO_ALLOW_BASH is unset")
|
||||
}
|
||||
// Truthy: the env opt-in is wired through the central policy builder.
|
||||
t.Setenv(sandbox.EnvAutoAllowBash, "true")
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); !got.AutoAllowBashWhenSandboxed {
|
||||
t.Fatal("ZERO_SANDBOX_AUTO_ALLOW_BASH=true must enable AutoAllowBashWhenSandboxed")
|
||||
}
|
||||
}
|
||||
|
||||
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
|
||||
t.Helper()
|
||||
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
|
||||
|
||||
+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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1094,7 +1094,8 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
"auth_scheme": "raw",
|
||||
"auth_header_value": "header-secret",
|
||||
"custom_headers": {"X-Zero": "1"},
|
||||
"model_id": "custom-model"
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": true
|
||||
}]
|
||||
}`)
|
||||
|
||||
@@ -1119,6 +1120,30 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
|
||||
if profile.CustomHeaders["X-Zero"] != "1" {
|
||||
t.Fatalf("CustomHeaders = %#v, want X-Zero header", profile.CustomHeaders)
|
||||
}
|
||||
if profile.ParseThinkTags == nil || !*profile.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want true", profile.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "custom",
|
||||
"providers": [{
|
||||
"name": "custom",
|
||||
"provider_kind": "openai-compatible",
|
||||
"base_url": "https://custom.example/v1",
|
||||
"model_id": "custom-model",
|
||||
"parse_think_tags": false
|
||||
}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Provider.ParseThinkTags == nil || *resolved.Provider.ParseThinkTags {
|
||||
t.Fatalf("ParseThinkTags = %#v, want explicit false", resolved.Provider.ParseThinkTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsUnknownProviderCatalogID(t *testing.T) {
|
||||
|
||||
@@ -34,6 +34,7 @@ type ProviderProfile struct {
|
||||
AuthHeaderValue string `json:"authHeaderValue,omitempty"`
|
||||
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
|
||||
profile.CustomHeaders != nil ||
|
||||
strings.TrimSpace(profile.Model) != "" ||
|
||||
profile.ParseThinkTags != nil ||
|
||||
strings.TrimSpace(profile.Description) != ""
|
||||
}
|
||||
|
||||
@@ -304,6 +306,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
CustomHeadersSnake map[string]string `json:"custom_headers"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"model_id"`
|
||||
ParseThinkTags *bool `json:"parseThinkTags"`
|
||||
ParseThinkTagsSnake *bool `json:"parse_think_tags"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
@@ -325,6 +329,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
|
||||
profile.AuthHeaderValue = firstNonEmpty(raw.AuthHeaderValue, raw.AuthHeaderValueSnake)
|
||||
profile.CustomHeaders = firstNonNilMap(raw.CustomHeaders, raw.CustomHeadersSnake)
|
||||
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
|
||||
profile.ParseThinkTags = firstNonNilBool(raw.ParseThinkTags, raw.ParseThinkTagsSnake)
|
||||
profile.Description = strings.TrimSpace(raw.Description)
|
||||
return nil
|
||||
}
|
||||
@@ -348,6 +353,15 @@ func firstNonNilMap(values ...map[string]string) map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonNilBool(values ...*bool) *bool {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyStringMap(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backoff bounds. Mirrors reference-daemon-code-agent-js/worker-manager.js:
|
||||
// backoffMs(consecutive) = jitter(min(1000 * 2**consecutive, 300000)).
|
||||
const (
|
||||
backoffUnit = time.Second
|
||||
backoffCap = 5 * time.Minute
|
||||
)
|
||||
|
||||
// backoffBase returns the un-jittered exponential delay for a 1-based restart
|
||||
// count: min(1s * 2^attempt, 5m). attempt<=0 yields the unit delay.
|
||||
func backoffBase(attempt int) time.Duration {
|
||||
if attempt <= 0 {
|
||||
return backoffUnit
|
||||
}
|
||||
// Shift carefully: cap before overflow. 2^attempt * 1s exceeds the cap well
|
||||
// before any int64 overflow (cap is 5m), so clamp once it would exceed it.
|
||||
d := backoffUnit
|
||||
for i := 0; i < attempt; i++ {
|
||||
d *= 2
|
||||
if d >= backoffCap {
|
||||
return backoffCap
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// BackoffWithJitter returns backoffBase(attempt) scaled by a uniform random
|
||||
// factor in [0.5, 1.5), matching worker-manager.js jitter(base) =
|
||||
// base*(0.5 + random()). The jitter de-synchronizes simultaneous restarts so a
|
||||
// fleet of workers does not thunder back at the same instant.
|
||||
func BackoffWithJitter(attempt int) time.Duration {
|
||||
base := backoffBase(attempt)
|
||||
factor := 0.5 + rand.Float64() //nolint:gosec // jitter, not security-sensitive
|
||||
return time.Duration(float64(base) * factor)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffBaseExponentialAndCap(t *testing.T) {
|
||||
cases := []struct {
|
||||
attempt int
|
||||
want time.Duration
|
||||
}{
|
||||
{0, 1 * time.Second},
|
||||
{1, 2 * time.Second},
|
||||
{2, 4 * time.Second},
|
||||
{3, 8 * time.Second},
|
||||
{20, backoffCap}, // far past the cap
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := backoffBase(tc.attempt); got != tc.want {
|
||||
t.Fatalf("backoffBase(%d) = %s, want %s", tc.attempt, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffWithJitterBounds(t *testing.T) {
|
||||
for attempt := 1; attempt <= 6; attempt++ {
|
||||
base := backoffBase(attempt)
|
||||
lo := time.Duration(float64(base) * 0.5)
|
||||
hi := time.Duration(float64(base) * 1.5)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := BackoffWithJitter(attempt)
|
||||
if got < lo || got >= hi {
|
||||
t.Fatalf("BackoffWithJitter(%d) = %s, want in [%s,%s)", attempt, got, lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is a control-socket client used by the `zero daemon run|attach|status|
|
||||
// stop` subcommands. It performs the version handshake on Dial.
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
// Dial connects to the daemon control socket and completes the handshake.
|
||||
func Dial(socketPath string) (*Client, error) {
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{conn: conn}
|
||||
if err := c.handshake(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
reply, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.Type != CtrlHelloOK {
|
||||
return fmt.Errorf("daemon: handshake rejected: %s", reply.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the connection.
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// Run starts/routes a session and streams its stream-json output lines to onLine
|
||||
// until the session ends. It returns the session's terminal error, if any.
|
||||
func (c *Client) Run(session, cwd, prompt string, args []string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlRun, Session: session, Cwd: cwd, Prompt: prompt, Args: args}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
// Attach streams a running session's output (buffered history + live) to onLine.
|
||||
func (c *Client) Attach(session string, onLine func(string)) error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlAttach, Session: session}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.streamLoop(onLine)
|
||||
}
|
||||
|
||||
func (c *Client) streamLoop(onLine func(string)) error {
|
||||
for {
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg.Type {
|
||||
case CtrlAck:
|
||||
// dispatch acknowledged; keep reading
|
||||
case CtrlData:
|
||||
if onLine != nil {
|
||||
onLine(msg.Line)
|
||||
}
|
||||
case CtrlEnd:
|
||||
return nil
|
||||
case CtrlError:
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
default:
|
||||
return fmt.Errorf("daemon: unexpected message %q", msg.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status requests the daemon/worker/session status report.
|
||||
func (c *Client) Status() (*StatusReport, error) {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlStatus}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return nil, fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
if msg.Type != CtrlStatusResult || msg.Status == nil {
|
||||
return nil, fmt.Errorf("daemon: unexpected status reply %q", msg.Type)
|
||||
}
|
||||
return msg.Status, nil
|
||||
}
|
||||
|
||||
// Shutdown asks the daemon to drain and stop.
|
||||
func (c *Client) Shutdown() error {
|
||||
if err := WriteControl(c.conn, Ctrl{Type: CtrlShutdown}); err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := ReadControl(c.conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Type == CtrlError {
|
||||
return fmt.Errorf("daemon: %s", msg.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/background"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// reentrancyMarkers are the env vars zero sets on a command it has ALREADY
|
||||
// wrapped in a sandbox. The daemon MUST strip them from every worker's
|
||||
// environment: a worker that inherited them would trip zero's re-entrancy guard
|
||||
// (sandbox.IsAlreadySandboxed needs BOTH) and run with a pass-through plan —
|
||||
// i.e. UNSANDBOXED — silently defeating the sandbox. Stripping them forces each
|
||||
// worker to (re)establish its own sandbox. This is the daemon's #1 security
|
||||
// invariant: it must NOT bypass the sandbox.
|
||||
var reentrancyMarkers = []string{sandbox.EnvSandboxed, sandbox.EnvSandboxBackend}
|
||||
|
||||
// scrubWorkerEnv returns env with the sandbox re-entrancy markers removed. Every
|
||||
// other variable is preserved — including the ZERO_SANDBOX_* policy config the
|
||||
// worker reads to rebuild its own policy — so the sandbox policy is PROPAGATED,
|
||||
// not bypassed. The caller's slice is never mutated.
|
||||
func scrubWorkerEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
drop := false
|
||||
for _, marker := range reentrancyMarkers {
|
||||
if strings.HasPrefix(kv, marker+"=") {
|
||||
drop = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drop {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// execWorker is a WorkerHandle backed by a `zero exec` child process speaking
|
||||
// stream-json on stdout.
|
||||
type execWorker struct {
|
||||
cmd *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
lines *scannerLines
|
||||
pid int
|
||||
}
|
||||
|
||||
func (w *execWorker) Stdout() Lines { return w.lines }
|
||||
func (w *execWorker) Pid() int { return w.pid }
|
||||
|
||||
func (w *execWorker) Wait() (int, error) {
|
||||
err := w.cmd.Wait()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
return -1, err
|
||||
}
|
||||
|
||||
func (w *execWorker) Kill() error {
|
||||
if w.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
// background.TerminateProcess is the cross-platform terminate (kills the
|
||||
// process group on POSIX, taskkill /T on Windows).
|
||||
return background.TerminateProcess(w.cmd.Process.Pid)
|
||||
}
|
||||
|
||||
// scannerLines adapts a bufio.Scanner to the Lines interface.
|
||||
type scannerLines struct {
|
||||
sc *bufio.Scanner
|
||||
}
|
||||
|
||||
func (s *scannerLines) Next() (string, bool, error) {
|
||||
if s.sc.Scan() {
|
||||
return s.sc.Text(), true, nil
|
||||
}
|
||||
if err := s.sc.Err(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// ExecLauncherConfig configures the production worker launcher.
|
||||
type ExecLauncherConfig struct {
|
||||
// Executable is the zero binary to spawn (defaults to os.Executable()).
|
||||
Executable string
|
||||
// BaseArgs are prepended before the per-session args (defaults to the headless
|
||||
// stream-json exec invocation `exec --output-format stream-json`).
|
||||
BaseArgs []string
|
||||
// Env is the base environment for workers (defaults to os.Environ()). The
|
||||
// re-entrancy markers are always scrubbed from it.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// NewExecLauncher builds a Launcher that spawns `zero exec` workers which speak
|
||||
// stream-json on stdout. Each worker:
|
||||
// - runs with the re-entrancy markers SCRUBBED from its env (so it establishes
|
||||
// its own sandbox; the daemon never bypasses the sandbox), while the rest of
|
||||
// the policy config/env is propagated;
|
||||
// - is placed in its own process group (background.ConfigureChildProcessGroup)
|
||||
// so Kill / drain can terminate it and any children cleanly;
|
||||
// - has its per-session working directory (spec.Cwd) and flags (spec.Args)
|
||||
// applied. The pool does the os/exec itself and uses internal/background only
|
||||
// for process-group setup + cross-platform terminate.
|
||||
func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
|
||||
exe := cfg.Executable
|
||||
if exe == "" {
|
||||
resolved, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: locate zero executable: %w", err)
|
||||
}
|
||||
exe = resolved
|
||||
}
|
||||
baseArgs := cfg.BaseArgs
|
||||
if baseArgs == nil {
|
||||
baseArgs = []string{"exec", "--output-format", "stream-json"}
|
||||
}
|
||||
baseEnv := cfg.Env
|
||||
if baseEnv == nil {
|
||||
baseEnv = os.Environ()
|
||||
}
|
||||
env := scrubWorkerEnv(baseEnv)
|
||||
|
||||
return func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
|
||||
args := make([]string, 0, len(baseArgs)+len(spec.Args))
|
||||
args = append(args, baseArgs...)
|
||||
args = append(args, spec.Args...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, exe, args...)
|
||||
cmd.Dir = spec.Cwd
|
||||
cmd.Env = env
|
||||
cmd.Stdin = nil // the prompt is passed via flags; no streamed stdin input
|
||||
background.ConfigureChildProcessGroup(cmd)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon: worker stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("daemon: start worker: %w", err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
// Allow long stream-json lines up to the control-frame cap.
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), MaxFrameSize)
|
||||
return &execWorker{
|
||||
cmd: cmd,
|
||||
stdout: stdout,
|
||||
lines: &scannerLines{sc: scanner},
|
||||
pid: cmd.Process.Pid,
|
||||
}, nil
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestScrubWorkerEnvRemovesReentrancyMarkers(t *testing.T) {
|
||||
in := []string{
|
||||
"PATH=/bin",
|
||||
sandbox.EnvSandboxed + "=1",
|
||||
"ZERO_SANDBOX_AUTO_ALLOW_BASH=1", // policy config — must be PRESERVED
|
||||
sandbox.EnvSandboxBackend + "=bubblewrap",
|
||||
"HOME=/home/u",
|
||||
}
|
||||
out := scrubWorkerEnv(in)
|
||||
|
||||
for _, kv := range out {
|
||||
if strings.HasPrefix(kv, sandbox.EnvSandboxed+"=") || strings.HasPrefix(kv, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("re-entrancy marker not scrubbed: %q", kv)
|
||||
}
|
||||
}
|
||||
want := map[string]bool{"PATH=/bin": true, "ZERO_SANDBOX_AUTO_ALLOW_BASH=1": true, "HOME=/home/u": true}
|
||||
got := map[string]bool{}
|
||||
for _, kv := range out {
|
||||
got[kv] = true
|
||||
}
|
||||
for w := range want {
|
||||
if !got[w] {
|
||||
t.Fatalf("scrub dropped a non-marker var: %q", w)
|
||||
}
|
||||
}
|
||||
if len(out) != len(want) {
|
||||
t.Fatalf("scrub result = %v, want exactly %d preserved vars", out, len(want))
|
||||
}
|
||||
if len(in) != 5 {
|
||||
t.Fatal("scrubWorkerEnv must not mutate the caller's slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecLauncherScrubsEnvEndToEnd proves the spawned WORKER PROCESS actually
|
||||
// receives an environment without the re-entrancy markers — the security
|
||||
// invariant. It uses /bin/sh to dump the worker env, so it is POSIX-only.
|
||||
func TestExecLauncherScrubsEnvEndToEnd(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses the POSIX env dumper /bin/sh -c env")
|
||||
}
|
||||
launcher, err := NewExecLauncher(ExecLauncherConfig{
|
||||
Executable: "/bin/sh",
|
||||
BaseArgs: []string{"-c", "env"},
|
||||
Env: append([]string{"KEEP=yes"},
|
||||
sandbox.EnvSandboxed+"=1",
|
||||
sandbox.EnvSandboxBackend+"=bubblewrap"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewExecLauncher: %v", err)
|
||||
}
|
||||
h, err := launcher(context.Background(), WorkerSpec{})
|
||||
if err != nil {
|
||||
t.Fatalf("launch: %v", err)
|
||||
}
|
||||
lines := h.Stdout()
|
||||
var got []string
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
t.Fatalf("read worker stdout: %v", lerr)
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
got = append(got, line)
|
||||
}
|
||||
if _, err := h.Wait(); err != nil {
|
||||
t.Fatalf("worker wait: %v", err)
|
||||
}
|
||||
|
||||
keepSeen := false
|
||||
for _, l := range got {
|
||||
if strings.HasPrefix(l, sandbox.EnvSandboxed+"=") || strings.HasPrefix(l, sandbox.EnvSandboxBackend+"=") {
|
||||
t.Fatalf("worker process still has a re-entrancy marker in its env: %q", l)
|
||||
}
|
||||
if l == "KEEP=yes" {
|
||||
keepSeen = true
|
||||
}
|
||||
}
|
||||
if !keepSeen {
|
||||
t.Fatalf("worker env lost a non-marker var (KEEP=yes); got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExecLauncherDefaults(t *testing.T) {
|
||||
// With no Executable it resolves the test binary's own path (os.Executable),
|
||||
// and constructs without error.
|
||||
if _, err := NewExecLauncher(ExecLauncherConfig{}); err != nil {
|
||||
t.Fatalf("NewExecLauncher with defaults: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
|
||||
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
|
||||
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
|
||||
// daemon recovers from an unclean shutdown without manual cleanup.
|
||||
|
||||
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
|
||||
|
||||
// fileLock is an acquired single-instance lock.
|
||||
type fileLock struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// processAlive reports whether pid is a live process. Implemented per-platform
|
||||
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
|
||||
var processAlive = osProcessAlive
|
||||
|
||||
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
|
||||
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
|
||||
// stub); nil uses the real processAlive.
|
||||
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
|
||||
if isAlive == nil {
|
||||
isAlive = processAlive
|
||||
}
|
||||
// At most two passes: create, or detect-stale-then-retry once.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A failed PID write would leave a malformed lock file that another
|
||||
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
|
||||
// the single-instance guarantee — so on write failure, remove it and fail.
|
||||
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, werr
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, cerr
|
||||
}
|
||||
return &fileLock{path: path}, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
pid, perr := readPidFile(path)
|
||||
if perr == nil && pid > 0 && isAlive(pid) {
|
||||
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
|
||||
}
|
||||
// Stale lock (dead PID or unreadable) — remove and retry once.
|
||||
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) {
|
||||
return nil, rmErr
|
||||
}
|
||||
}
|
||||
return nil, ErrAlreadyRunning
|
||||
}
|
||||
|
||||
// release removes the lock file. Safe to call once.
|
||||
func (l *fileLock) release() error {
|
||||
if l == nil || l.path == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(l.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPidFile reads and parses the PID recorded in a lock file.
|
||||
func readPidFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on POSIX. Signal 0 does
|
||||
// not deliver a signal; it only checks existence/permission. ESRCH means no such
|
||||
// process (dead); EPERM means it exists but we may not signal it (alive).
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLockSingleInstance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
alive := func(int) bool { return true }
|
||||
|
||||
l1, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("first acquireLock: %v", err)
|
||||
}
|
||||
// Second acquire while the holder is "alive" must be refused.
|
||||
if _, err := acquireLock(path, alive); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second acquireLock err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
// After release, a new acquire succeeds.
|
||||
if err := l1.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
l2, err := acquireLock(path, alive)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire after release: %v", err)
|
||||
}
|
||||
_ = l2.release()
|
||||
}
|
||||
|
||||
func TestLockStaleRecovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
// Simulate a stale lock from a crashed daemon: a PID file whose process is
|
||||
// dead.
|
||||
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
|
||||
t.Fatalf("write stale lock: %v", err)
|
||||
}
|
||||
dead := func(int) bool { return false }
|
||||
l, err := acquireLock(path, dead)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-lock recovery failed: %v", err)
|
||||
}
|
||||
// The lock now records OUR pid, not the stale one.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
|
||||
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
|
||||
}
|
||||
_ = l.release()
|
||||
}
|
||||
|
||||
func TestLockReleaseRemovesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.lock")
|
||||
l, err := acquireLock(path, func(int) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatalf("acquireLock: %v", err)
|
||||
}
|
||||
if err := l.release(); err != nil {
|
||||
t.Fatalf("release: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("lock file still present after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAliveSelfAndDead(t *testing.T) {
|
||||
if !osProcessAlive(os.Getpid()) {
|
||||
t.Fatal("osProcessAlive(self) = false, want true")
|
||||
}
|
||||
// PID 0 / negative are never live.
|
||||
if osProcessAlive(0) || osProcessAlive(-1) {
|
||||
t.Fatal("osProcessAlive must reject non-positive pids")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// osProcessAlive reports whether pid is a live process on Windows. It opens the
|
||||
// process for limited query access; a successful open with a non-exited status
|
||||
// means the PID is live. x/sys is already a module dependency.
|
||||
func osProcessAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
|
||||
// Could not query; treat the open as proof of life (conservative: do not
|
||||
// reclaim a lock we are unsure about).
|
||||
return true
|
||||
}
|
||||
const stillActive = 259 // STILL_ACTIVE
|
||||
return code == stillActive
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultDir returns the per-user directory for the daemon's runtime files:
|
||||
// $XDG_RUNTIME_DIR/zero when set (a tmpfs owned by the user on Linux), otherwise
|
||||
// ~/.zero. Mirrors supervisor.js / config.js choosing a per-user runtime dir.
|
||||
func DefaultDir() (string, error) {
|
||||
if rt := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); rt != "" {
|
||||
return filepath.Join(rt, "zero"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".zero"), nil
|
||||
}
|
||||
|
||||
// Paths bundles the daemon's socket, lock, and status file paths.
|
||||
type Paths struct {
|
||||
Socket string
|
||||
Lock string
|
||||
Status string
|
||||
}
|
||||
|
||||
// DefaultPaths returns the daemon control-socket, lock, and status file paths
|
||||
// under DefaultDir.
|
||||
func DefaultPaths() (Paths, error) {
|
||||
dir, err := DefaultDir()
|
||||
if err != nil {
|
||||
return Paths{}, err
|
||||
}
|
||||
return Paths{
|
||||
Socket: filepath.Join(dir, "daemon.sock"),
|
||||
Lock: filepath.Join(dir, "daemon.lock"),
|
||||
Status: filepath.Join(dir, "daemon.status"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exit codes a worker may use to signal restart policy. Mirrors
|
||||
// reference-daemon-code-agent-js/worker-manager.js EXIT_TEMPFAIL/EXIT_PERMANENT.
|
||||
const (
|
||||
// ExitTempfail asks for a retry after a fixed cool-off (transient failure).
|
||||
ExitTempfail = 75
|
||||
// ExitPermanent asks the daemon to STOP retrying this work (fatal).
|
||||
ExitPermanent = 76
|
||||
)
|
||||
|
||||
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
|
||||
// PoolOptions field is left zero.
|
||||
const (
|
||||
defaultPoolSize = 4
|
||||
defaultMaxAttempts = 5
|
||||
defaultKillTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ErrPoolDraining is returned by Run once the pool is shutting down.
|
||||
var ErrPoolDraining = errors.New("daemon: pool is draining")
|
||||
|
||||
// ErrPermanent is returned when a worker exits ExitPermanent or the attempt cap
|
||||
// is exhausted; the request must not be retried.
|
||||
var ErrPermanent = errors.New("daemon: worker failed permanently")
|
||||
|
||||
// WorkerSpec describes the worker to launch for one session attempt.
|
||||
type WorkerSpec struct {
|
||||
Session string
|
||||
Cwd string
|
||||
// Args are the per-session exec flags (e.g. --prompt ...), parsed by the CLI
|
||||
// and appended after `exec` by the production launcher.
|
||||
Args []string
|
||||
}
|
||||
|
||||
// WorkerHandle is a launched worker process the pool supervises. The production
|
||||
// launcher wraps an exec.Cmd running `zero exec -i/-o stream-json`; tests inject
|
||||
// a fake. Stdout yields the worker's stream-json event lines.
|
||||
type WorkerHandle interface {
|
||||
Stdout() Lines
|
||||
// Wait blocks until the worker exits and returns its process exit code.
|
||||
Wait() (int, error)
|
||||
// Kill force-terminates the worker (used on drain timeout).
|
||||
Kill() error
|
||||
Pid() int
|
||||
}
|
||||
|
||||
// Lines is a stream of stream-json output lines from a worker. Next returns the
|
||||
// next line and io-EOF-style ok=false when the stream ends.
|
||||
type Lines interface {
|
||||
Next() (line string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Launcher spawns a worker for a single attempt. It must apply the sandbox/env
|
||||
// policy (the production launcher scrubs the re-entrancy markers so the worker
|
||||
// re-establishes its own sandbox — see newExecLauncher).
|
||||
type Launcher func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error)
|
||||
|
||||
// PoolOptions configures a Pool. Zero fields take documented defaults.
|
||||
type PoolOptions struct {
|
||||
// Size is the max number of concurrent workers (lease slots). Sessions beyond
|
||||
// this queue until a slot frees.
|
||||
Size int
|
||||
// Launcher spawns a worker. Required.
|
||||
Launcher Launcher
|
||||
// MaxAttempts caps the total attempts per request (first + retries). When
|
||||
// exhausted, Run returns ErrPermanent (mirrors capping consecutive restarts).
|
||||
MaxAttempts int
|
||||
// KillTimeout bounds graceful termination before SIGKILL-equivalent on drain.
|
||||
KillTimeout time.Duration
|
||||
// Backoff returns the delay before retry attempt n (1-based crash count).
|
||||
// Defaults to BackoffWithJitter; tests inject a deterministic function.
|
||||
Backoff func(attempt int) time.Duration
|
||||
// TempfailDelay is the cool-off after an ExitTempfail exit. Defaults to 30s.
|
||||
TempfailDelay time.Duration
|
||||
// Log, when set, receives one line per lifecycle event.
|
||||
Log func(string)
|
||||
}
|
||||
|
||||
// Pool is a bounded, self-healing worker pool. A crashed worker never takes down
|
||||
// the pool: Run retries on a fresh worker with backoff up to MaxAttempts, and an
|
||||
// ExitPermanent worker stops retries. Drain stops new work and kills stragglers.
|
||||
type Pool struct {
|
||||
opts PoolOptions
|
||||
slots chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
draining bool
|
||||
active map[int]WorkerHandle // pid -> handle, for drain/kill + status
|
||||
nextID int
|
||||
|
||||
drainOnce sync.Once
|
||||
drained chan struct{}
|
||||
}
|
||||
|
||||
// workerStat tracks one in-flight request's restart count (local to Run).
|
||||
type workerStat struct {
|
||||
id int
|
||||
restarts int
|
||||
}
|
||||
|
||||
// NewPool builds a pool from opts, filling defaults.
|
||||
func NewPool(opts PoolOptions) (*Pool, error) {
|
||||
if opts.Launcher == nil {
|
||||
return nil, errors.New("daemon: pool requires a Launcher")
|
||||
}
|
||||
if opts.Size <= 0 {
|
||||
opts.Size = defaultPoolSize
|
||||
}
|
||||
if opts.MaxAttempts <= 0 {
|
||||
opts.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if opts.KillTimeout <= 0 {
|
||||
opts.KillTimeout = defaultKillTimeout
|
||||
}
|
||||
if opts.TempfailDelay <= 0 {
|
||||
opts.TempfailDelay = 30 * time.Second
|
||||
}
|
||||
if opts.Backoff == nil {
|
||||
opts.Backoff = BackoffWithJitter
|
||||
}
|
||||
return &Pool{
|
||||
opts: opts,
|
||||
slots: make(chan struct{}, opts.Size),
|
||||
active: map[int]WorkerHandle{},
|
||||
drained: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Size returns the configured max concurrency.
|
||||
func (p *Pool) Size() int { return p.opts.Size }
|
||||
|
||||
func (p *Pool) logf(format string, args ...any) {
|
||||
if p.opts.Log != nil {
|
||||
p.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// acquire takes a lease slot, queuing until one frees or ctx is cancelled / the
|
||||
// pool drains.
|
||||
func (p *Pool) acquire(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
draining := p.draining
|
||||
p.mu.Unlock()
|
||||
if draining {
|
||||
return ErrPoolDraining
|
||||
}
|
||||
select {
|
||||
case p.slots <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.drained:
|
||||
return ErrPoolDraining
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) release() { <-p.slots }
|
||||
|
||||
// QueueDepth reports how many lease slots are currently in use.
|
||||
func (p *Pool) QueueDepth() int { return len(p.slots) }
|
||||
|
||||
// Sink receives a worker's stream-json output lines for one request.
|
||||
type Sink interface {
|
||||
Line(line string)
|
||||
}
|
||||
|
||||
// Run leases a worker slot and dispatches spec to a worker, streaming its
|
||||
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
|
||||
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
|
||||
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
|
||||
// queues when all slots are busy. The returned int is the final worker exit code.
|
||||
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
|
||||
if err := p.acquire(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer p.release()
|
||||
|
||||
// Lease hook: a sink that tracks queue->running state is notified the instant
|
||||
// a worker slot is leased (mirrors lease.js acquiring a worker for a session).
|
||||
if s, ok := sink.(interface{ Started() }); ok {
|
||||
s.Started()
|
||||
}
|
||||
|
||||
stat := p.newStat()
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= p.opts.MaxAttempts; attempt++ {
|
||||
if p.isDraining() {
|
||||
return 0, ErrPoolDraining
|
||||
}
|
||||
code, err := p.runOnce(ctx, stat.id, spec, sink)
|
||||
switch {
|
||||
case err != nil:
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
p.logf("worker %d launch/run error: %v", stat.id, err)
|
||||
case code == 0:
|
||||
return 0, nil // clean success
|
||||
case code == ExitPermanent:
|
||||
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
|
||||
return code, ErrPermanent
|
||||
case code == ExitTempfail:
|
||||
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
|
||||
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
|
||||
if !p.sleep(ctx, p.opts.TempfailDelay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
continue // tempfail retries do not count against the crash backoff
|
||||
default:
|
||||
lastErr = fmt.Errorf("worker %d exited code=%d", stat.id, code)
|
||||
}
|
||||
|
||||
if attempt == p.opts.MaxAttempts {
|
||||
break
|
||||
}
|
||||
stat.restarts++
|
||||
delay := p.opts.Backoff(stat.restarts)
|
||||
p.logf("worker %d restart %d after backoff %s", stat.id, stat.restarts, delay)
|
||||
if !p.sleep(ctx, delay) {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = ErrPermanent
|
||||
}
|
||||
return ExitPermanent, fmt.Errorf("%w: %v", ErrPermanent, lastErr)
|
||||
}
|
||||
|
||||
// runOnce launches a single worker, pumps its output to sink, and returns its
|
||||
// exit code. The worker handle is tracked so Drain can kill it.
|
||||
func (p *Pool) runOnce(ctx context.Context, id int, spec WorkerSpec, sink Sink) (int, error) {
|
||||
handle, err := p.opts.Launcher(ctx, spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
p.track(handle)
|
||||
defer p.untrack(handle)
|
||||
|
||||
// Pump stdout lines until the stream ends.
|
||||
lines := handle.Stdout()
|
||||
for {
|
||||
line, ok, lerr := lines.Next()
|
||||
if lerr != nil {
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if sink != nil {
|
||||
sink.Line(line)
|
||||
}
|
||||
}
|
||||
return handle.Wait()
|
||||
}
|
||||
|
||||
func (p *Pool) newStat() *workerStat {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.nextID++
|
||||
return &workerStat{id: p.nextID}
|
||||
}
|
||||
|
||||
func (p *Pool) track(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
p.active[h.Pid()] = h
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) untrack(h WorkerHandle) {
|
||||
p.mu.Lock()
|
||||
delete(p.active, h.Pid())
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pool) isDraining() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.draining
|
||||
}
|
||||
|
||||
// sleep waits d, returning false if ctx is cancelled first. A non-positive d
|
||||
// returns immediately.
|
||||
func (p *Pool) sleep(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Drain stops accepting new work, gives in-flight workers a grace window
|
||||
// (KillTimeout) to finish on their own, then force-kills any straggler. It
|
||||
// returns as soon as the pool is idle (graceful) or the window elapses. Safe to
|
||||
// call once; subsequent calls are no-ops.
|
||||
func (p *Pool) Drain() {
|
||||
p.drainOnce.Do(func() {
|
||||
p.mu.Lock()
|
||||
p.draining = true
|
||||
p.mu.Unlock()
|
||||
close(p.drained)
|
||||
|
||||
// Grace window: poll until idle or the deadline.
|
||||
deadline := time.Now().Add(p.opts.KillTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
p.mu.Lock()
|
||||
n := len(p.active)
|
||||
p.mu.Unlock()
|
||||
if n == 0 {
|
||||
return // all workers drained gracefully
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Timed out: force-kill remaining workers.
|
||||
p.mu.Lock()
|
||||
handles := make([]WorkerHandle, 0, len(p.active))
|
||||
for _, h := range p.active {
|
||||
handles = append(handles, h)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, h := range handles {
|
||||
p.logf("drain: killing straggler worker pid=%d", h.Pid())
|
||||
_ = h.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// WorkerStats returns a snapshot of the currently-active (busy) workers, bounded
|
||||
// by the pool size. The on-demand pool keeps no idle workers, so an empty result
|
||||
// means the pool is idle.
|
||||
func (p *Pool) WorkerStats() []WorkerStatus {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]WorkerStatus, 0, len(p.active))
|
||||
for pid := range p.active {
|
||||
out = append(out, WorkerStatus{PID: pid, State: "busy"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- test doubles ---------------------------------------------------------
|
||||
|
||||
type fakeLines struct {
|
||||
lines []string
|
||||
i int
|
||||
}
|
||||
|
||||
func (f *fakeLines) Next() (string, bool, error) {
|
||||
if f.i >= len(f.lines) {
|
||||
return "", false, nil
|
||||
}
|
||||
l := f.lines[f.i]
|
||||
f.i++
|
||||
return l, true, nil
|
||||
}
|
||||
|
||||
type fakeWorker struct {
|
||||
pid int
|
||||
out []string
|
||||
exitCode int
|
||||
killed int32
|
||||
waitCh chan struct{} // when non-nil, Wait blocks until closed (drain tests)
|
||||
}
|
||||
|
||||
func (w *fakeWorker) Stdout() Lines { return &fakeLines{lines: w.out} }
|
||||
func (w *fakeWorker) Wait() (int, error) {
|
||||
if w.waitCh != nil {
|
||||
<-w.waitCh
|
||||
}
|
||||
return w.exitCode, nil
|
||||
}
|
||||
func (w *fakeWorker) Kill() error {
|
||||
atomic.StoreInt32(&w.killed, 1)
|
||||
if w.waitCh != nil {
|
||||
select {
|
||||
case <-w.waitCh:
|
||||
default:
|
||||
close(w.waitCh)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (w *fakeWorker) Pid() int { return w.pid }
|
||||
|
||||
// seqLauncher hands out the given workers in order, then errors.
|
||||
func seqLauncher(workers ...*fakeWorker) (Launcher, *int32) {
|
||||
var calls int32
|
||||
l := func(_ context.Context, _ WorkerSpec) (WorkerHandle, error) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
idx := int(n) - 1
|
||||
if idx >= len(workers) {
|
||||
return nil, errors.New("no more fake workers")
|
||||
}
|
||||
return workers[idx], nil
|
||||
}
|
||||
return l, &calls
|
||||
}
|
||||
|
||||
type collectSink struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
}
|
||||
|
||||
func (c *collectSink) Line(line string) {
|
||||
c.mu.Lock()
|
||||
c.lines = append(c.lines, line)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- tests ----------------------------------------------------------------
|
||||
|
||||
func TestPoolRunStreamsAndSucceeds(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, out: []string{"a", "b"}, exitCode: 0})
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if len(sink.lines) != 2 || sink.lines[0] != "a" || sink.lines[1] != "b" {
|
||||
t.Fatalf("sink lines = %v, want [a b]", sink.lines)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunRetriesWithBackoff(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 2, exitCode: 1}, // crash
|
||||
&fakeWorker{pid: 3, exitCode: 0, out: []string{"ok"}}, // success
|
||||
)
|
||||
var backoffArgs []int
|
||||
pool, _ := NewPool(PoolOptions{
|
||||
Size: 1, Launcher: launcher, MaxAttempts: 5,
|
||||
Backoff: func(attempt int) time.Duration { backoffArgs = append(backoffArgs, attempt); return 0 },
|
||||
})
|
||||
sink := &collectSink{}
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (2 crashes + success)", *calls)
|
||||
}
|
||||
// Backoff invoked between attempts with an increasing 1-based restart count.
|
||||
if len(backoffArgs) != 2 || backoffArgs[0] != 1 || backoffArgs[1] != 2 {
|
||||
t.Fatalf("backoff attempts = %v, want [1 2]", backoffArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunPermanentStopsImmediately(t *testing.T) {
|
||||
launcher, calls := seqLauncher(&fakeWorker{pid: 1, exitCode: ExitPermanent})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, Backoff: func(int) time.Duration { return 0 }})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent", err)
|
||||
}
|
||||
if code != ExitPermanent {
|
||||
t.Fatalf("Run code = %d, want %d", code, ExitPermanent)
|
||||
}
|
||||
if *calls != 1 {
|
||||
t.Fatalf("permanent exit must not retry; launcher calls = %d, want 1", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunCapExhausted(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: 1},
|
||||
&fakeWorker{pid: 2, exitCode: 1},
|
||||
&fakeWorker{pid: 3, exitCode: 1},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 3, Backoff: func(int) time.Duration { return 0 }})
|
||||
_, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("Run err = %v, want ErrPermanent after cap", err)
|
||||
}
|
||||
if *calls != 3 {
|
||||
t.Fatalf("launcher calls = %d, want 3 (== MaxAttempts)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRunTempfailRetries(t *testing.T) {
|
||||
launcher, calls := seqLauncher(
|
||||
&fakeWorker{pid: 1, exitCode: ExitTempfail},
|
||||
&fakeWorker{pid: 2, exitCode: 0, out: []string{"ok"}},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, TempfailDelay: time.Millisecond})
|
||||
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
|
||||
}
|
||||
if *calls != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 (tempfail + success)", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolQueuesWhenFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
first := &fakeWorker{pid: 1, waitCh: block}
|
||||
second := &fakeWorker{pid: 2, exitCode: 0}
|
||||
launcher, _ := seqLauncher(first, second)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
close(started)
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
}()
|
||||
<-started
|
||||
// Wait until the first run holds the only slot.
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
secondDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{})
|
||||
close(secondDone)
|
||||
}()
|
||||
// The second run must NOT complete while the slot is held.
|
||||
select {
|
||||
case <-secondDone:
|
||||
t.Fatal("second run completed while the only slot was busy (no queueing)")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
// Free the slot; the queued run now proceeds.
|
||||
close(block)
|
||||
select {
|
||||
case <-secondDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("queued run did not proceed after slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolDrainKillsStraggler(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
straggler := &fakeWorker{pid: 1, waitCh: block}
|
||||
launcher, _ := seqLauncher(straggler)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 1, KillTimeout: 10 * time.Millisecond})
|
||||
|
||||
runDone := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
|
||||
close(runDone)
|
||||
}()
|
||||
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
|
||||
|
||||
pool.Drain() // KillTimeout elapses, straggler is force-killed
|
||||
if atomic.LoadInt32(&straggler.killed) != 1 {
|
||||
t.Fatal("Drain must kill a straggler still running after KillTimeout")
|
||||
}
|
||||
select {
|
||||
case <-runDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("run did not finish after drain kill")
|
||||
}
|
||||
|
||||
// A new Run after drain is refused.
|
||||
if _, err := pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{}); !errors.Is(err, ErrPoolDraining) {
|
||||
t.Fatalf("Run after drain err = %v, want ErrPoolDraining", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package daemon implements zero's long-running daemon/server mode: a local
|
||||
// control server that supervises a pool of headless `zero exec` worker processes
|
||||
// and routes multiple agent sessions to them over an owner-only local socket.
|
||||
//
|
||||
// It reuses zero's existing building blocks rather than inventing new ones:
|
||||
// - internal/background : child-process group setup + cross-platform terminate.
|
||||
// - internal/streamjson : the line-based agent event protocol on worker stdio.
|
||||
// - internal/cli (exec) : a worker is a `zero exec -i/-o stream-json` process.
|
||||
//
|
||||
// The control plane (this file) is a small framed codec mirrored from the
|
||||
// reference daemon's protocol.js: a 4-byte big-endian length prefix, a 1-byte
|
||||
// frame kind, then a JSON control payload, with a 1 MiB frame cap and a
|
||||
// version-negotiation handshake. The agent event stream itself stays stream-json.
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Wire constants. Mirrors reference-daemon-code-agent-js/protocol.js
|
||||
// (FRAME_DATA/FRAME_CTRL, HEADER_SIZE, MAX_FRAME_SIZE, PROTO_VERSION).
|
||||
const (
|
||||
// ProtoVersion is the control-protocol version advertised in the handshake.
|
||||
ProtoVersion = 1
|
||||
// MaxFrameSize caps a single control frame payload at 1 MiB. A larger
|
||||
// advertised length is rejected before any allocation (fail closed).
|
||||
MaxFrameSize = 1 << 20
|
||||
// frameHeaderSize is 4 bytes big-endian length + 1 byte kind.
|
||||
frameHeaderSize = 5
|
||||
)
|
||||
|
||||
// FrameKind tags a frame's payload. Mirrors protocol.js FRAME_DATA/FRAME_CTRL.
|
||||
type FrameKind uint8
|
||||
|
||||
const (
|
||||
// KindData carries an opaque payload (e.g. a stream-json line). Reserved for
|
||||
// future use; the control plane uses KindCtrl.
|
||||
KindData FrameKind = 0
|
||||
// KindCtrl carries a JSON-encoded control message (see Ctrl).
|
||||
KindCtrl FrameKind = 1
|
||||
)
|
||||
|
||||
// ErrFrameTooLarge is returned when a frame's advertised payload length exceeds
|
||||
// MaxFrameSize. The reader rejects it without allocating the buffer.
|
||||
var ErrFrameTooLarge = errors.New("daemon: control frame exceeds size cap")
|
||||
|
||||
// ErrUnknownFrameKind is returned for a frame whose kind byte is not recognized.
|
||||
var ErrUnknownFrameKind = errors.New("daemon: unknown control frame kind")
|
||||
|
||||
// WriteFrame writes a single framed message: [uint32BE len][uint8 kind][payload].
|
||||
// It rejects an over-cap payload before writing anything so a buggy/hostile caller
|
||||
// cannot emit a frame a conforming reader would refuse.
|
||||
func WriteFrame(w io.Writer, kind FrameKind, payload []byte) error {
|
||||
if len(payload) > MaxFrameSize {
|
||||
return fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, len(payload), MaxFrameSize)
|
||||
}
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], uint32(len(payload)))
|
||||
header[4] = byte(kind)
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadFrame reads a single framed message. It validates the advertised length
|
||||
// against MaxFrameSize BEFORE allocating, so an oversize or hostile frame is
|
||||
// rejected (ErrFrameTooLarge) rather than triggering a huge allocation. An
|
||||
// unrecognized kind byte yields ErrUnknownFrameKind after the payload is drained
|
||||
// so the stream stays in sync.
|
||||
func ReadFrame(r io.Reader) (FrameKind, []byte, error) {
|
||||
header := make([]byte, frameHeaderSize)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(header[:4])
|
||||
if length > MaxFrameSize {
|
||||
return 0, nil, fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, length, MaxFrameSize)
|
||||
}
|
||||
kind := FrameKind(header[4])
|
||||
payload := make([]byte, length)
|
||||
if length > 0 {
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
if kind != KindData && kind != KindCtrl {
|
||||
return kind, nil, ErrUnknownFrameKind
|
||||
}
|
||||
return kind, payload, nil
|
||||
}
|
||||
|
||||
// CtrlType is the discriminator for a control message.
|
||||
type CtrlType string
|
||||
|
||||
const (
|
||||
// CtrlHello is the client's opening handshake; Version is the highest control
|
||||
// version it speaks.
|
||||
CtrlHello CtrlType = "hello"
|
||||
// CtrlHelloOK is the daemon's handshake reply with the negotiated Version.
|
||||
CtrlHelloOK CtrlType = "hello_ok"
|
||||
// CtrlRun asks the daemon to create/route a session and stream its output.
|
||||
CtrlRun CtrlType = "run"
|
||||
// CtrlAttach asks the daemon to attach the client to a running session's stream.
|
||||
CtrlAttach CtrlType = "attach"
|
||||
// CtrlStatus requests daemon/worker/session status.
|
||||
CtrlStatus CtrlType = "status"
|
||||
// CtrlStatusResult carries the status payload (see StatusReport in Status).
|
||||
CtrlStatusResult CtrlType = "status_result"
|
||||
// CtrlData carries one stream-json line of agent output back to the client.
|
||||
CtrlData CtrlType = "data"
|
||||
// CtrlAck acknowledges receipt/dispatch of a request (at-least-once dispatch).
|
||||
CtrlAck CtrlType = "ack"
|
||||
// CtrlEnd marks the end of a session's stream.
|
||||
CtrlEnd CtrlType = "end"
|
||||
// CtrlShutdown asks the daemon to drain and stop.
|
||||
CtrlShutdown CtrlType = "shutdown"
|
||||
// CtrlError carries a human-readable error for the client.
|
||||
CtrlError CtrlType = "error"
|
||||
)
|
||||
|
||||
// Ctrl is a control message carried in a KindCtrl frame as JSON. Unused fields
|
||||
// are omitted so frames stay small.
|
||||
type Ctrl struct {
|
||||
Type CtrlType `json:"type"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
// Line is one stream-json event line (for CtrlData).
|
||||
Line string `json:"line,omitempty"`
|
||||
// Message is a human-readable detail (for CtrlError / CtrlAck).
|
||||
Message string `json:"message,omitempty"`
|
||||
// Status is populated on CtrlStatusResult.
|
||||
Status *StatusReport `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// WriteControl marshals a control message into a KindCtrl frame.
|
||||
func WriteControl(w io.Writer, msg Ctrl) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFrame(w, KindCtrl, payload)
|
||||
}
|
||||
|
||||
// ReadControl reads the next frame and decodes it as a control message. A
|
||||
// non-control frame yields ErrUnknownFrameKind so the caller fails closed.
|
||||
func ReadControl(r io.Reader) (Ctrl, error) {
|
||||
kind, payload, err := ReadFrame(r)
|
||||
if err != nil {
|
||||
return Ctrl{}, err
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
return Ctrl{}, ErrUnknownFrameKind
|
||||
}
|
||||
var msg Ctrl
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
return Ctrl{}, fmt.Errorf("daemon: decode control message: %w", err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// NegotiateVersion picks the protocol version both ends can speak: the lower of
|
||||
// the client's advertised version and ProtoVersion. A non-positive client
|
||||
// version is invalid and yields ok=false so the daemon can reject the connection.
|
||||
func NegotiateVersion(clientVersion int) (int, bool) {
|
||||
if clientVersion <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
if clientVersion < ProtoVersion {
|
||||
return clientVersion, true
|
||||
}
|
||||
return ProtoVersion, true
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrameRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
payloads := [][]byte{[]byte("hello"), {}, []byte(strings.Repeat("x", 4096))}
|
||||
for _, p := range payloads {
|
||||
if err := WriteFrame(&buf, KindCtrl, p); err != nil {
|
||||
t.Fatalf("WriteFrame: %v", err)
|
||||
}
|
||||
}
|
||||
for i, want := range payloads {
|
||||
kind, got, err := ReadFrame(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFrame[%d]: %v", i, err)
|
||||
}
|
||||
if kind != KindCtrl {
|
||||
t.Fatalf("frame[%d] kind = %d, want KindCtrl", i, kind)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("frame[%d] payload = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlRoundTrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
msg := Ctrl{Type: CtrlRun, Session: "s1", Cwd: "/tmp", Args: []string{"--prompt", "hi"}}
|
||||
if err := WriteControl(&buf, msg); err != nil {
|
||||
t.Fatalf("WriteControl: %v", err)
|
||||
}
|
||||
got, err := ReadControl(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadControl: %v", err)
|
||||
}
|
||||
if got.Type != CtrlRun || got.Session != "s1" || got.Cwd != "/tmp" || len(got.Args) != 2 {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFrameRejectsOversize(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := WriteFrame(&buf, KindCtrl, make([]byte, MaxFrameSize+1))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("WriteFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("oversize WriteFrame must not emit any bytes, wrote %d", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameRejectsOversizeBeforeAlloc(t *testing.T) {
|
||||
// A header advertising a 4 GiB payload must be rejected without reading or
|
||||
// allocating that payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], MaxFrameSize+1)
|
||||
header[4] = byte(KindCtrl)
|
||||
_, _, err := ReadFrame(bytes.NewReader(header))
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("ReadFrame oversize err = %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameUnknownKind(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Hand-write a frame with an unknown kind byte (7) and a small payload.
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 2)
|
||||
header[4] = 7
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("ab"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if !errors.Is(err, ErrUnknownFrameKind) {
|
||||
t.Fatalf("unknown kind err = %v, want ErrUnknownFrameKind", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFrameTruncated(t *testing.T) {
|
||||
// Header promises 10 bytes but only 3 follow → unexpected EOF.
|
||||
var buf bytes.Buffer
|
||||
header := make([]byte, frameHeaderSize)
|
||||
binary.BigEndian.PutUint32(header[:4], 10)
|
||||
header[4] = byte(KindCtrl)
|
||||
buf.Write(header)
|
||||
buf.Write([]byte("abc"))
|
||||
_, _, err := ReadFrame(&buf)
|
||||
if err == nil || errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("truncated frame err = %v, want a read error", err)
|
||||
}
|
||||
if !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("truncated frame err = %v, want io.ErrUnexpectedEOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegotiateVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
client int
|
||||
want int
|
||||
wantOK bool
|
||||
}{
|
||||
{client: 1, want: 1, wantOK: true},
|
||||
{client: 99, want: ProtoVersion, wantOK: true}, // clamp down to ours
|
||||
{client: 0, want: 0, wantOK: false},
|
||||
{client: -3, want: 0, wantOK: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := NegotiateVersion(tc.client)
|
||||
if got != tc.want || ok != tc.wantOK {
|
||||
t.Fatalf("NegotiateVersion(%d) = (%d,%v), want (%d,%v)", tc.client, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server is the daemon control plane. Mirrors reference-daemon-code-agent-js/
|
||||
// supervisor.js (single-instance lock, status file, lifecycle) + the accept loop
|
||||
// that routes framed control requests to the SessionManager/Pool. It listens on
|
||||
// an owner-only local Unix socket and NEVER binds a TCP port.
|
||||
type Server struct {
|
||||
opts ServerOptions
|
||||
startedAt time.Time
|
||||
|
||||
listener net.Listener
|
||||
lock *fileLock
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
wg sync.WaitGroup
|
||||
shutdownOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// ServerOptions configures a Server.
|
||||
type ServerOptions struct {
|
||||
Paths Paths
|
||||
Manager *SessionManager
|
||||
Pool *Pool
|
||||
Version int
|
||||
Now func() time.Time
|
||||
Log func(string)
|
||||
isAlive func(int) bool // test hook for the single-instance lock
|
||||
}
|
||||
|
||||
// NewServer validates options and builds a Server.
|
||||
func NewServer(opts ServerOptions) (*Server, error) {
|
||||
if opts.Manager == nil || opts.Pool == nil {
|
||||
return nil, errors.New("daemon: server requires a Pool and SessionManager")
|
||||
}
|
||||
if opts.Paths.Socket == "" || opts.Paths.Lock == "" || opts.Paths.Status == "" {
|
||||
return nil, errors.New("daemon: server requires socket, lock, and status paths")
|
||||
}
|
||||
if opts.Version <= 0 {
|
||||
opts.Version = ProtoVersion
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Server{
|
||||
opts: opts,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) logf(format string, args ...any) {
|
||||
if s.opts.Log != nil {
|
||||
s.opts.Log(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
// Serve acquires the single-instance lock, binds the owner-only control socket,
|
||||
// writes the status file, and serves connections until Shutdown. It blocks. On
|
||||
// return it has released the lock and removed the socket/status files.
|
||||
func (s *Server) Serve() error {
|
||||
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.lock = lock
|
||||
defer s.cleanup()
|
||||
|
||||
// A leftover socket file from an unclean exit would make Listen fail with
|
||||
// "address already in use"; we hold the lock, so any socket here is stale.
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
|
||||
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: bind control socket: %w", err)
|
||||
}
|
||||
s.listener = listener
|
||||
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
|
||||
return fmt.Errorf("daemon: harden control socket: %w", err)
|
||||
}
|
||||
s.startedAt = s.opts.Now()
|
||||
if err := s.writeStatusFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logf("daemon listening on %s (pid %d)", s.opts.Paths.Socket, os.Getpid())
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
s.wg.Wait()
|
||||
return nil // clean shutdown
|
||||
default:
|
||||
// Transient accept error during normal operation.
|
||||
s.logf("accept error: %v", err)
|
||||
return fmt.Errorf("daemon: accept: %w", err)
|
||||
}
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.handleConn(conn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops accepting connections, cancels in-flight runs, drains the pool,
|
||||
// and removes the socket/lock/status files. Safe to call multiple times.
|
||||
func (s *Server) Shutdown() {
|
||||
s.shutdownOnce.Do(func() {
|
||||
close(s.done)
|
||||
s.cancel() // stop in-flight pool runs
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
s.opts.Pool.Drain()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) cleanup() {
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
_ = os.Remove(s.opts.Paths.Socket)
|
||||
_ = os.Remove(s.opts.Paths.Status)
|
||||
if s.lock != nil {
|
||||
_ = s.lock.release()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeStatusFile() error {
|
||||
status := StatusFile{
|
||||
PID: os.Getpid(),
|
||||
Socket: s.opts.Paths.Socket,
|
||||
Version: s.opts.Version,
|
||||
StartedAt: s.startedAt,
|
||||
}
|
||||
data, err := json.MarshalIndent(status, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
|
||||
return fmt.Errorf("daemon: write status file: %w", err)
|
||||
}
|
||||
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()
|
||||
|
||||
hello, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hello.Type != CtrlHello {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "expected hello"})
|
||||
return
|
||||
}
|
||||
version, ok := NegotiateVersion(hello.Version)
|
||||
if !ok {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "unsupported protocol version"})
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlHelloOK, Version: version}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cmd, err := ReadControl(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch cmd.Type {
|
||||
case CtrlRun:
|
||||
s.handleRun(conn, cmd)
|
||||
case CtrlAttach:
|
||||
s.handleAttach(conn, cmd)
|
||||
case CtrlStatus:
|
||||
s.handleStatus(conn)
|
||||
case CtrlShutdown:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Message: "shutting down"})
|
||||
s.Shutdown()
|
||||
default:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: fmt.Sprintf("unknown command %q", cmd.Type)})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRun(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "run requires a session id"})
|
||||
return
|
||||
}
|
||||
args := cmd.Args
|
||||
if cmd.Prompt != "" {
|
||||
args = append(args, "--prompt", cmd.Prompt)
|
||||
}
|
||||
sess, err := s.opts.Manager.Start(s.ctx, WorkerSpec{Session: cmd.Session, Cwd: cmd.Cwd, Args: args})
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: sess.ID()})
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
func (s *Server) handleAttach(conn net.Conn, cmd Ctrl) {
|
||||
if cmd.Session == "" {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "attach requires a session id"})
|
||||
return
|
||||
}
|
||||
buffered, live, cancel, err := s.opts.Manager.Attach(cmd.Session)
|
||||
if err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
sess, _ := s.opts.Manager.Get(cmd.Session)
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: cmd.Session})
|
||||
s.streamToClient(conn, sess, buffered, live)
|
||||
}
|
||||
|
||||
// streamToClient writes the buffered history then live lines as CtrlData frames,
|
||||
// finishing with CtrlEnd (or CtrlError if the session failed). A write error
|
||||
// (client disconnected) ends the stream without affecting the session.
|
||||
func (s *Server) streamToClient(conn net.Conn, sess *Session, buffered []string, live <-chan string) {
|
||||
for _, line := range buffered {
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
s.finishStream(conn, sess)
|
||||
return
|
||||
}
|
||||
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
||||
return
|
||||
}
|
||||
case <-s.done:
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) finishStream(conn net.Conn, sess *Session) {
|
||||
if sess != nil {
|
||||
if err := sess.Err(); err != nil {
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlError, Session: sess.ID(), Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlEnd})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(conn net.Conn) {
|
||||
report := StatusReport{
|
||||
PID: os.Getpid(),
|
||||
Version: s.opts.Version,
|
||||
Socket: s.opts.Paths.Socket,
|
||||
StartedAt: s.startedAt,
|
||||
PoolSize: s.opts.Pool.Size(),
|
||||
Workers: s.opts.Pool.WorkerStats(),
|
||||
Sessions: s.opts.Manager.Statuses(),
|
||||
QueueDepth: s.opts.Pool.QueueDepth(),
|
||||
}
|
||||
_ = WriteControl(conn, Ctrl{Type: CtrlStatusResult, Status: &report})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
Socket: filepath.Join(dir, "d.sock"),
|
||||
Lock: filepath.Join(dir, "d.lock"),
|
||||
Status: filepath.Join(dir, "d.status"),
|
||||
}
|
||||
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPool: %v", err)
|
||||
}
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
return srv, paths
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("file %s did not appear within timeout", path)
|
||||
}
|
||||
|
||||
func TestServerEndToEnd(t *testing.T) {
|
||||
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// --- run a session and collect its stream-json output ---
|
||||
runClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(run): %v", err)
|
||||
}
|
||||
var got []string
|
||||
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
runClient.Close()
|
||||
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
|
||||
t.Fatalf("streamed lines = %v, want %v", got, out)
|
||||
}
|
||||
|
||||
// --- status reflects the session ---
|
||||
statusClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(status): %v", err)
|
||||
}
|
||||
report, err := statusClient.Status()
|
||||
statusClient.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if report.PoolSize != 2 {
|
||||
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
|
||||
}
|
||||
foundSession := false
|
||||
for _, s := range report.Sessions {
|
||||
if s.ID == "s1" {
|
||||
foundSession = true
|
||||
if s.State != string(SessionDone) {
|
||||
t.Fatalf("session s1 state = %s, want done", s.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSession {
|
||||
t.Fatalf("status missing session s1: %+v", report.Sessions)
|
||||
}
|
||||
|
||||
// --- attach to the finished session sees the buffered history ---
|
||||
attachClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(attach): %v", err)
|
||||
}
|
||||
var attached []string
|
||||
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
attachClient.Close()
|
||||
if len(attached) != 2 {
|
||||
t.Fatalf("attach lines = %v, want 2 buffered", attached)
|
||||
}
|
||||
|
||||
// --- graceful stop, daemon cleans up ---
|
||||
stopClient, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial(stop): %v", err)
|
||||
}
|
||||
if err := stopClient.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
stopClient.Close()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve returned error: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Serve did not return after shutdown")
|
||||
}
|
||||
// Socket, status, and lock files are removed on exit.
|
||||
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
|
||||
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSecondInstanceFails(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv1, paths := newTestServer(t, launcher)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv1.Serve() }()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
// A second server on the same paths must refuse to start (single instance).
|
||||
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
|
||||
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
|
||||
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
|
||||
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
|
||||
srv1.Shutdown()
|
||||
<-serveErr
|
||||
}
|
||||
|
||||
func TestServerRejectsUnknownCommand(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
srv, paths := newTestServer(t, launcher)
|
||||
go func() { _ = srv.Serve() }()
|
||||
defer srv.Shutdown()
|
||||
waitForFile(t, paths.Status)
|
||||
|
||||
client, err := Dial(paths.Socket)
|
||||
if err != nil {
|
||||
t.Fatalf("Dial: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
// Run with an empty session id is rejected with an error frame.
|
||||
err = client.Run("", "", "hi", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("run with empty session id must return an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Session/lease/roster model. Mirrors reference-daemon-code-agent-js/
|
||||
// session-manager.js (sessionID -> worker + metrics), lease.js (one active
|
||||
// request per worker) and roster.js (the live session registry). Because zero's
|
||||
// `exec` worker is one-shot, each session is served by exactly one worker via
|
||||
// Pool.Run, so "one active request per worker" holds inherently and the pool's
|
||||
// bounded slots provide the queue-when-full behavior.
|
||||
|
||||
// SessionState is a session's lifecycle phase.
|
||||
type SessionState string
|
||||
|
||||
const (
|
||||
// SessionQueued: created, waiting for a free pool slot.
|
||||
SessionQueued SessionState = "queued"
|
||||
// SessionRunning: a worker is leased and producing output.
|
||||
SessionRunning SessionState = "running"
|
||||
// SessionDone: the worker finished cleanly.
|
||||
SessionDone SessionState = "done"
|
||||
// SessionFailed: the worker failed permanently / the run errored.
|
||||
SessionFailed SessionState = "failed"
|
||||
)
|
||||
|
||||
// ErrSessionExists is returned when starting a session ID already in use.
|
||||
var ErrSessionExists = errors.New("daemon: session already exists")
|
||||
|
||||
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
|
||||
var ErrSessionNotFound = errors.New("daemon: session not found")
|
||||
|
||||
const defaultSessionBuffer = 1024
|
||||
|
||||
// Session is one routed agent run. It buffers recent output (so a late `attach`
|
||||
// sees history) and fans live lines out to subscribers. It implements the pool's
|
||||
// Sink (Line) and the lease "started" hook (Started).
|
||||
type Session struct {
|
||||
id string
|
||||
cwd string
|
||||
|
||||
mu sync.Mutex
|
||||
state SessionState
|
||||
buffer []string
|
||||
maxBuffer int
|
||||
lines int
|
||||
finished bool
|
||||
err error
|
||||
exitCode int
|
||||
subscribers map[int]chan string
|
||||
nextSub int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSession(id, cwd string, maxBuffer int) *Session {
|
||||
if maxBuffer <= 0 {
|
||||
maxBuffer = defaultSessionBuffer
|
||||
}
|
||||
return &Session{
|
||||
id: id,
|
||||
cwd: cwd,
|
||||
state: SessionQueued,
|
||||
maxBuffer: maxBuffer,
|
||||
subscribers: map[int]chan string{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the session ID.
|
||||
func (s *Session) ID() string { return s.id }
|
||||
|
||||
// Done is closed when the session finishes (success or failure).
|
||||
func (s *Session) Done() <-chan struct{} { return s.done }
|
||||
|
||||
// Err returns the terminal error (nil on clean completion); valid after Done.
|
||||
func (s *Session) Err() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.err
|
||||
}
|
||||
|
||||
// State returns the current lifecycle phase.
|
||||
func (s *Session) State() SessionState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
// Started marks the session running once the pool leases a worker (lease hook).
|
||||
func (s *Session) Started() {
|
||||
s.mu.Lock()
|
||||
if s.state == SessionQueued {
|
||||
s.state = SessionRunning
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Line records and broadcasts one stream-json output line.
|
||||
func (s *Session) Line(line string) {
|
||||
s.mu.Lock()
|
||||
s.lines++
|
||||
s.buffer = append(s.buffer, line)
|
||||
if len(s.buffer) > s.maxBuffer {
|
||||
// Drop the oldest line; the ring keeps only the most recent maxBuffer.
|
||||
s.buffer = s.buffer[len(s.buffer)-s.maxBuffer:]
|
||||
}
|
||||
subs := make([]chan string, 0, len(s.subscribers))
|
||||
for _, ch := range s.subscribers {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
// Non-blocking: a slow/stalled subscriber must not stall the worker pump.
|
||||
// Dropped live lines remain available via the buffer for a fresh attach.
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) finish(exitCode int, err error) {
|
||||
s.mu.Lock()
|
||||
s.finished = true
|
||||
s.exitCode = exitCode
|
||||
s.err = err
|
||||
if err != nil {
|
||||
s.state = SessionFailed
|
||||
} else {
|
||||
s.state = SessionDone
|
||||
}
|
||||
subs := s.subscribers
|
||||
s.subscribers = map[int]chan string{}
|
||||
s.mu.Unlock()
|
||||
for _, ch := range subs {
|
||||
close(ch)
|
||||
}
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// Subscribe returns the buffered history plus a channel of subsequent live lines
|
||||
// and a cancel func. If the session has already finished, the live channel is
|
||||
// returned already closed. Mirrors attach.js.
|
||||
func (s *Session) Subscribe() (buffered []string, live <-chan string, cancel func()) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
buffered = append([]string(nil), s.buffer...)
|
||||
if s.finished {
|
||||
ch := make(chan string)
|
||||
close(ch)
|
||||
return buffered, ch, func() {}
|
||||
}
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
ch := make(chan string, 256)
|
||||
s.subscribers[id] = ch
|
||||
cancel = func() {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.subscribers[id]; ok {
|
||||
delete(s.subscribers, id)
|
||||
close(c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return buffered, ch, cancel
|
||||
}
|
||||
|
||||
func (s *Session) status() SessionStatus {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return SessionStatus{ID: s.id, State: string(s.state), Lines: s.lines}
|
||||
}
|
||||
|
||||
func (s *Session) isFinished() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.finished
|
||||
}
|
||||
|
||||
// defaultMaxSessions bounds the retained session registry so a long-running
|
||||
// daemon does not accumulate finished sessions without limit.
|
||||
const defaultMaxSessions = 256
|
||||
|
||||
// SessionManager owns the live session registry and routes each session to a
|
||||
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
|
||||
// retained (so a late `attach` sees history) up to MaxSessions, past which the
|
||||
// oldest FINISHED ones are evicted; running/queued sessions are never evicted.
|
||||
type SessionManager struct {
|
||||
pool *Pool
|
||||
maxBuffer int
|
||||
maxSessions int
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
order []string // creation order, for FIFO eviction of finished sessions
|
||||
}
|
||||
|
||||
// SessionManagerOptions configures a SessionManager.
|
||||
type SessionManagerOptions struct {
|
||||
Pool *Pool
|
||||
MaxBuffer int // per-session output ring size; 0 => default
|
||||
// MaxSessions caps the retained session registry; 0 => default. The oldest
|
||||
// FINISHED sessions are evicted once the cap is exceeded.
|
||||
MaxSessions int
|
||||
}
|
||||
|
||||
// NewSessionManager builds a manager over pool.
|
||||
func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
|
||||
if opts.Pool == nil {
|
||||
return nil, errors.New("daemon: session manager requires a Pool")
|
||||
}
|
||||
maxSessions := opts.MaxSessions
|
||||
if maxSessions <= 0 {
|
||||
maxSessions = defaultMaxSessions
|
||||
}
|
||||
return &SessionManager{
|
||||
pool: opts.Pool,
|
||||
maxBuffer: opts.MaxBuffer,
|
||||
maxSessions: maxSessions,
|
||||
sessions: map[string]*Session{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start creates a session for spec and dispatches it to the pool. It returns the
|
||||
// Session immediately (non-blocking); the run proceeds in the background and the
|
||||
// session transitions queued -> running -> done/failed. A duplicate ID is
|
||||
// rejected with ErrSessionExists.
|
||||
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if _, exists := m.sessions[spec.Session]; exists {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrSessionExists
|
||||
}
|
||||
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
|
||||
m.sessions[spec.Session] = sess
|
||||
m.order = append(m.order, spec.Session)
|
||||
m.pruneLocked()
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
code, err := m.pool.Run(ctx, spec, sess)
|
||||
sess.finish(code, err)
|
||||
}()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// pruneLocked evicts the oldest FINISHED sessions while the registry exceeds
|
||||
// maxSessions, so a long-running daemon does not retain finished sessions without
|
||||
// bound. Running/queued sessions are never evicted (a transient burst can exceed
|
||||
// the cap until those finish). Caller must hold m.mu.
|
||||
func (m *SessionManager) pruneLocked() {
|
||||
if len(m.sessions) <= m.maxSessions {
|
||||
return
|
||||
}
|
||||
kept := m.order[:0]
|
||||
for _, id := range m.order {
|
||||
s, ok := m.sessions[id]
|
||||
if !ok {
|
||||
continue // already removed
|
||||
}
|
||||
if len(m.sessions) > m.maxSessions && s.isFinished() {
|
||||
delete(m.sessions, id)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, id)
|
||||
}
|
||||
m.order = kept
|
||||
}
|
||||
|
||||
// Get returns a session by ID.
|
||||
func (m *SessionManager) Get(id string) (*Session, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[id]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Attach returns the buffered history + live stream for an existing session.
|
||||
func (m *SessionManager) Attach(id string) (buffered []string, live <-chan string, cancel func(), err error) {
|
||||
s, ok := m.Get(id)
|
||||
if !ok {
|
||||
return nil, nil, nil, ErrSessionNotFound
|
||||
}
|
||||
b, l, c := s.Subscribe()
|
||||
return b, l, c, nil
|
||||
}
|
||||
|
||||
// Statuses returns a snapshot of all sessions, sorted by ID for stable output.
|
||||
func (m *SessionManager) Statuses() []SessionStatus {
|
||||
m.mu.Lock()
|
||||
sessions := make([]*Session, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
out := make([]SessionStatus, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
out = append(out, s.status())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func drain(t *testing.T, buffered []string, live <-chan string) []string {
|
||||
t.Helper()
|
||||
got := append([]string(nil), buffered...)
|
||||
timeout := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case line, ok := <-live:
|
||||
if !ok {
|
||||
return got
|
||||
}
|
||||
got = append(got, line)
|
||||
case <-timeout:
|
||||
t.Fatal("timed out draining session stream")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStartRoutesAndStreams(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"e1", "e2", "e3"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
sess, err := mgr.Start(context.Background(), WorkerSpec{Session: "s1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
buffered, live, cancel := sess.Subscribe()
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 3 || got[0] != "e1" || got[2] != "e3" {
|
||||
t.Fatalf("streamed lines = %v, want [e1 e2 e3]", got)
|
||||
}
|
||||
<-sess.Done()
|
||||
if sess.State() != SessionDone {
|
||||
t.Fatalf("state = %s, want done", sess.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionDuplicateIDRejected(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); !errors.Is(err, ErrSessionExists) {
|
||||
t.Fatalf("second Start err = %v, want ErrSessionExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
a := &fakeWorker{pid: 1, waitCh: block}
|
||||
b := &fakeWorker{pid: 2, out: []string{"b1"}, exitCode: 0}
|
||||
launcher, calls := seqLauncher(a, b)
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
|
||||
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
|
||||
waitFor(t, func() bool { return sa.State() == SessionRunning })
|
||||
|
||||
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
|
||||
// b must stay queued (its worker not yet launched) while a holds the slot.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if sb.State() != SessionQueued {
|
||||
t.Fatalf("session b state = %s, want queued while pool full", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 1 {
|
||||
t.Fatalf("launcher calls = %d, want 1 (b not launched yet)", n)
|
||||
}
|
||||
|
||||
close(block) // a finishes, slot frees, b proceeds
|
||||
<-sb.Done()
|
||||
if sb.State() != SessionDone {
|
||||
t.Fatalf("session b state = %s, want done", sb.State())
|
||||
}
|
||||
if n := atomic.LoadInt32(calls); n != 2 {
|
||||
t.Fatalf("launcher calls = %d, want 2 after slot freed", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAttachAfterFinishSeesBuffer(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"x", "y"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "s"})
|
||||
<-sess.Done()
|
||||
|
||||
buffered, live, cancel, err := mgr.Attach("s")
|
||||
if err != nil {
|
||||
t.Fatalf("Attach: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
got := drain(t, buffered, live)
|
||||
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
|
||||
t.Fatalf("attach buffered = %v, want [x y]", got)
|
||||
}
|
||||
if _, _, _, err := mgr.Attach("missing"); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("Attach(missing) err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerEvictsFinishedOverCap(t *testing.T) {
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, out: []string{"a"}, exitCode: 0},
|
||||
&fakeWorker{pid: 2, out: []string{"b"}, exitCode: 0},
|
||||
&fakeWorker{pid: 3, out: []string{"c"}, exitCode: 0},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
|
||||
|
||||
for _, id := range []string{"s1", "s2", "s3"} {
|
||||
s, err := mgr.Start(context.Background(), WorkerSpec{Session: id})
|
||||
if err != nil {
|
||||
t.Fatalf("Start(%s): %v", id, err)
|
||||
}
|
||||
<-s.Done() // finish before starting the next so prune can evict it
|
||||
}
|
||||
|
||||
// Past the cap of 2, the oldest FINISHED session (s1) is evicted; the two
|
||||
// newest are retained.
|
||||
if _, ok := mgr.Get("s1"); ok {
|
||||
t.Fatal("oldest finished session must be evicted past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("s2"); !ok {
|
||||
t.Fatal("s2 must be retained")
|
||||
}
|
||||
if _, ok := mgr.Get("s3"); !ok {
|
||||
t.Fatal("s3 (newest) must be retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
|
||||
// All sessions are running (workers block), so none are evicted even past cap.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
launcher, _ := seqLauncher(
|
||||
&fakeWorker{pid: 1, waitCh: block},
|
||||
&fakeWorker{pid: 2, waitCh: block},
|
||||
)
|
||||
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 1})
|
||||
|
||||
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
|
||||
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
|
||||
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
|
||||
if _, ok := mgr.Get("r1"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
if _, ok := mgr.Get("r2"); !ok {
|
||||
t.Fatal("a running session must never be evicted, even past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStatuses(t *testing.T) {
|
||||
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
|
||||
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
|
||||
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
|
||||
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "only"})
|
||||
<-sess.Done()
|
||||
st := mgr.Statuses()
|
||||
if len(st) != 1 || st[0].ID != "only" || st[0].State != string(SessionDone) || st[0].Lines != 1 {
|
||||
t.Fatalf("statuses = %+v, want one done session with 1 line", st)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
|
||||
// limit so we surface a clear error instead of a cryptic bind failure. macOS
|
||||
// allows 104 bytes incl. the NUL (so 103 chars); Linux allows 108. 103 is the
|
||||
// safe cross-platform ceiling.
|
||||
const maxUnixSocketPath = 103
|
||||
|
||||
// secureSocketParent creates the socket's parent directory owner-only (0700 on
|
||||
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
|
||||
func secureSocketParent(socketPath string) error {
|
||||
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
|
||||
}
|
||||
|
||||
// checkSocketPathLength rejects an over-long unix socket path before bind.
|
||||
func checkSocketPathLength(socketPath string) error {
|
||||
if len(socketPath) > maxUnixSocketPath {
|
||||
return fmt.Errorf("daemon: socket path too long (%d > %d): %s", len(socketPath), maxUnixSocketPath, socketPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package daemon
|
||||
|
||||
import "os"
|
||||
|
||||
// hardenSocketFile restricts the control socket to its owner (0600) so no other
|
||||
// local user can connect. Combined with the 0700 parent directory this makes the
|
||||
// control plane owner-only, as required by the security model.
|
||||
func hardenSocketFile(path string) error {
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package daemon
|
||||
|
||||
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
|
||||
// mode bits, and the daemon places the socket under the per-user profile
|
||||
// directory, which is already ACL-restricted to the owner. A dedicated restricted
|
||||
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
|
||||
func hardenSocketFile(string) error { return nil }
|
||||
@@ -0,0 +1,43 @@
|
||||
package daemon
|
||||
|
||||
import "time"
|
||||
|
||||
// StatusReport is the daemon/worker/session snapshot returned by `zero daemon
|
||||
// status` (CtrlStatusResult). Mirrors reference-daemon-code-agent-js/status.js.
|
||||
type StatusReport struct {
|
||||
PID int `json:"pid"`
|
||||
Version int `json:"version"`
|
||||
Socket string `json:"socket"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
PoolSize int `json:"poolSize"`
|
||||
Workers []WorkerStatus `json:"workers"`
|
||||
Sessions []SessionStatus `json:"sessions"`
|
||||
QueueDepth int `json:"queueDepth"`
|
||||
}
|
||||
|
||||
// WorkerStatus is one worker's lifecycle snapshot.
|
||||
type WorkerStatus struct {
|
||||
ID int `json:"id"`
|
||||
PID int `json:"pid"`
|
||||
State string `json:"state"`
|
||||
ConsecutiveCrashes int `json:"consecutiveCrashes"`
|
||||
Restarts int `json:"restarts"`
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// SessionStatus is one session's routing/metrics snapshot.
|
||||
type SessionStatus struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Lines int `json:"lines"`
|
||||
}
|
||||
|
||||
// StatusFile is the on-disk daemon status document (pid, socket, version,
|
||||
// started-at), written next to the lock so `status`/`stop` work without a live
|
||||
// connection. Mirrors supervisor.js's status file.
|
||||
type StatusFile struct {
|
||||
PID int `json:"pid"`
|
||||
Socket string `json:"socket"`
|
||||
Version int `json:"version"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
}
|
||||
@@ -158,6 +158,9 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
if emptyProviderProfile(profile) {
|
||||
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
|
||||
}
|
||||
if strings.TrimSpace(profile.Model) == "" {
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is required.", map[string]any{"provider": providerName(profile)})
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
|
||||
@@ -165,7 +168,7 @@ func providerModelCheck(profile config.ProviderProfile) Check {
|
||||
model, err := registry.Require(profile.Model)
|
||||
if err != nil {
|
||||
if profile.ProviderKind == config.ProviderKindOpenAICompatible || profile.ProviderKind == config.ProviderKindAnthropicCompat {
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider. Run `zero doctor --connectivity` to validate the endpoint and auth.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
|
||||
}
|
||||
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
|
||||
}
|
||||
|
||||
@@ -86,6 +86,96 @@ func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelUnknownOpenAICompatibleModelWarnsPassThrough(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openrouter",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
Model: "vendor/new-dynamic-model",
|
||||
},
|
||||
})
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("unknown openai-compatible model should warn, not fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil {
|
||||
t.Fatalf("expected provider.model check: %#v", report.Checks)
|
||||
}
|
||||
if check.Status != StatusWarn {
|
||||
t.Fatalf("provider.model status = %s, want warn: %#v", check.Status, check)
|
||||
}
|
||||
if !strings.Contains(check.Message, "runtime will pass it through") || !strings.Contains(check.Message, "doctor --connectivity") {
|
||||
t.Fatalf("provider.model message should explain pass-through connectivity validation, got %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelMissingModelFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "ollama-cloud",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://ollama.com/v1",
|
||||
Model: " ",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("missing model should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "required") {
|
||||
t.Fatalf("expected missing model failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelBuiltInUnknownModelFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
Model: "vendor/new-dynamic-model",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("built-in provider unknown model should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "unknown Zero model") {
|
||||
t.Fatalf("expected built-in unknown model failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderModelRegisteredProviderMismatchFails(t *testing.T) {
|
||||
report := Run(Options{
|
||||
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
|
||||
Runtime: "go",
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
BaseURL: config.OpenAIBaseURL,
|
||||
Model: "gemini-2.5-pro",
|
||||
},
|
||||
})
|
||||
|
||||
if report.OK {
|
||||
t.Fatalf("registered provider mismatch should fail: %#v", report)
|
||||
}
|
||||
check := report.Check("provider.model")
|
||||
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "not available for provider") {
|
||||
t.Fatalf("expected registered provider mismatch failure: %#v", report.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDoctorConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const minimalPDFTextChunkSize = 80
|
||||
|
||||
// buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws
|
||||
// the given text. It computes a real cross-reference table and trailer so a
|
||||
// pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test
|
||||
@@ -28,10 +30,11 @@ func buildMinimalPDF(text string) []byte {
|
||||
startObj() // object 2: page tree
|
||||
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
||||
|
||||
pageHeight := minimalPDFPageHeight(text)
|
||||
startObj() // object 3: page
|
||||
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
|
||||
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
|
||||
|
||||
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
|
||||
content := minimalPDFTextContent(text, pageHeight-92)
|
||||
startObj() // object 4: content stream
|
||||
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
|
||||
buf.WriteString(content)
|
||||
@@ -52,6 +55,56 @@ func buildMinimalPDF(text string) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func minimalPDFPageHeight(text string) int {
|
||||
lines := (len(text) + minimalPDFTextChunkSize - 1) / minimalPDFTextChunkSize
|
||||
if lines < 1 {
|
||||
lines = 1
|
||||
}
|
||||
height := 184 + lines*10
|
||||
if height < 792 {
|
||||
return 792
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
func minimalPDFTextContent(text string, startY int) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("BT /F1 8 Tf 10 TL 72 ")
|
||||
content.WriteString(strconv.Itoa(startY))
|
||||
content.WriteString(" Td ")
|
||||
for index := 0; len(text) > 0; index++ {
|
||||
if index > 0 {
|
||||
content.WriteString(" T* ")
|
||||
}
|
||||
chunk := text
|
||||
if len(chunk) > minimalPDFTextChunkSize {
|
||||
cut := minimalPDFTextChunkSize
|
||||
for cut > 0 && !utf8RuneStart(chunk[cut]) {
|
||||
cut--
|
||||
}
|
||||
if cut == 0 {
|
||||
cut = minimalPDFTextChunkSize
|
||||
}
|
||||
chunk = text[:cut]
|
||||
}
|
||||
content.WriteString("(")
|
||||
content.WriteString(escapePDFLiteral(chunk))
|
||||
content.WriteString(") Tj")
|
||||
text = text[len(chunk):]
|
||||
}
|
||||
content.WriteString(" ET")
|
||||
return content.String()
|
||||
}
|
||||
|
||||
func escapePDFLiteral(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `(`, `\(`)
|
||||
text = strings.ReplaceAll(text, `)`, `\)`)
|
||||
text = strings.ReplaceAll(text, "\r", `\r`)
|
||||
text = strings.ReplaceAll(text, "\n", `\n`)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestIsPDF(t *testing.T) {
|
||||
if !isPDF(buildMinimalPDF("hi")) {
|
||||
t.Fatal("isPDF should accept real %PDF- bytes")
|
||||
@@ -159,7 +212,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
|
||||
// Many short lines so the *extracted text* (not the file) exceeds the cap.
|
||||
var body strings.Builder
|
||||
line := "The quick brown fox jumps over the lazy dog. "
|
||||
for body.Len() < MaxDocumentTextBytes+4096 {
|
||||
for body.Len() < MaxDocumentTextBytes+65536 {
|
||||
body.WriteString(line)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
|
||||
@@ -173,7 +226,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
|
||||
t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes)
|
||||
}
|
||||
if !doc.Truncated {
|
||||
t.Fatal("Truncated should be set when extracted text is capped")
|
||||
t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages)
|
||||
}
|
||||
if !strings.Contains(doc.Text, documentTruncatedMarker) {
|
||||
t.Fatal("truncated text should carry the truncation marker")
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package keyring provides a small, dependency-free secret store backed by the
|
||||
// operating system's native credential tooling: the `security` keychain CLI on
|
||||
// macOS and `secret-tool` (libsecret) on Linux. It stores a single secret string
|
||||
// per (service, account). Windows and other platforms report unsupported.
|
||||
//
|
||||
// It shells out to the OS tools rather than taking a third-party dependency. On
|
||||
// macOS the secret is passed to `security` as an argument, so it is briefly
|
||||
// visible to other processes via the process list; on Linux the secret is passed
|
||||
// over stdin and is not exposed in the argument vector. Callers that need to keep
|
||||
// a secret out of the process list on macOS should prefer the file backend.
|
||||
package keyring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandTimeout bounds a single keyring tool invocation.
|
||||
const commandTimeout = 10 * time.Second
|
||||
|
||||
// ErrUnsupported is returned when no OS keyring backend is available for the
|
||||
// current platform.
|
||||
var ErrUnsupported = errors.New("keyring: no OS keyring backend on this platform")
|
||||
|
||||
// runner executes name with args and optional stdin, returning stdout. It is the
|
||||
// single seam tests replace to drive the platform command logic without touching
|
||||
// a real keychain.
|
||||
type runner func(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error)
|
||||
|
||||
// Keyring is an OS-native secret store.
|
||||
type Keyring struct {
|
||||
run runner
|
||||
goos string
|
||||
}
|
||||
|
||||
// New returns a Keyring for the current platform.
|
||||
func New() *Keyring {
|
||||
return &Keyring{run: execRunner, goos: runtime.GOOS}
|
||||
}
|
||||
|
||||
// Available reports whether this platform has a supported keyring backend. The
|
||||
// backing tool (`secret-tool` on Linux) must also be installed; a missing tool
|
||||
// surfaces as an error from Get/Set/Delete.
|
||||
func (k *Keyring) Available() bool {
|
||||
switch k.goos {
|
||||
case "darwin", "linux":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set stores secret under (service, account), replacing any existing value.
|
||||
func (k *Keyring) Set(service, account, secret string) error {
|
||||
if err := validate(service, account); err != nil {
|
||||
return err
|
||||
}
|
||||
switch k.goos {
|
||||
case "darwin":
|
||||
// -U updates the item if it already exists rather than failing.
|
||||
_, err := k.exec(nil, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
|
||||
return wrap("set", err)
|
||||
case "linux":
|
||||
// secret-tool reads the secret from stdin, keeping it out of the argv.
|
||||
_, err := k.exec([]byte(secret), "secret-tool", "store", "--label", "zero", "service", service, "account", account)
|
||||
return wrap("set", err)
|
||||
default:
|
||||
return ErrUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the secret stored under (service, account). The bool is false when
|
||||
// no entry exists (which is not an error).
|
||||
func (k *Keyring) Get(service, account string) (string, bool, error) {
|
||||
if err := validate(service, account); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
switch k.goos {
|
||||
case "darwin":
|
||||
out, err := k.exec(nil, "security", "find-generic-password", "-s", service, "-a", account, "-w")
|
||||
if err != nil {
|
||||
if isNotFound(err, securityNotFoundExit) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, wrap("get", err)
|
||||
}
|
||||
return strings.TrimRight(string(out), "\r\n"), true, nil
|
||||
case "linux":
|
||||
out, err := k.exec(nil, "secret-tool", "lookup", "service", service, "account", account)
|
||||
if err != nil {
|
||||
if isNotFound(err, secretToolNotFoundExit) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, wrap("get", err)
|
||||
}
|
||||
value := strings.TrimRight(string(out), "\r\n")
|
||||
if value == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return value, true, nil
|
||||
default:
|
||||
return "", false, ErrUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes the entry under (service, account), reporting whether one
|
||||
// existed.
|
||||
func (k *Keyring) Delete(service, account string) (bool, error) {
|
||||
if err := validate(service, account); err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch k.goos {
|
||||
case "darwin":
|
||||
_, err := k.exec(nil, "security", "delete-generic-password", "-s", service, "-a", account)
|
||||
if err != nil {
|
||||
if isNotFound(err, securityNotFoundExit) {
|
||||
return false, nil
|
||||
}
|
||||
return false, wrap("delete", err)
|
||||
}
|
||||
return true, nil
|
||||
case "linux":
|
||||
// `secret-tool clear` always exits 0, so probe existence first.
|
||||
_, existed, err := k.Get(service, account)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := k.exec(nil, "secret-tool", "clear", "service", service, "account", account); err != nil {
|
||||
return false, wrap("delete", err)
|
||||
}
|
||||
return existed, nil
|
||||
default:
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func (k *Keyring) exec(stdin []byte, name string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
return k.run(ctx, name, stdin, args...)
|
||||
}
|
||||
|
||||
func execRunner(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
if len(stdin) > 0 {
|
||||
cmd.Stdin = bytes.NewReader(stdin)
|
||||
}
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
return out.Bytes(), &runError{err: err, stderr: strings.TrimSpace(errBuf.String())}
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// runError carries a tool failure with its stderr and preserves the underlying
|
||||
// error for errors.As (so exit-code / not-found detection still works).
|
||||
type runError struct {
|
||||
err error
|
||||
stderr string
|
||||
}
|
||||
|
||||
func (e *runError) Error() string {
|
||||
if e.stderr != "" {
|
||||
return e.stderr
|
||||
}
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *runError) Unwrap() error { return e.err }
|
||||
|
||||
// Not-found exit codes for the OS tools: macOS `security` exits 44
|
||||
// (errSecItemNotFound) when no matching item exists; `secret-tool` exits 1 when a
|
||||
// lookup finds nothing. Any other non-zero exit is a real failure.
|
||||
const (
|
||||
securityNotFoundExit = 44
|
||||
secretToolNotFoundExit = 1
|
||||
)
|
||||
|
||||
// isNotFound reports whether err is a tool exit whose code is one of the given
|
||||
// "no such entry" codes (as opposed to a missing binary or a genuine failure,
|
||||
// which must not be masked). It matches on the ExitCode behavior (satisfied by
|
||||
// *exec.ExitError) so the logic is testable without spawning a real process.
|
||||
func isNotFound(err error, codes ...int) bool {
|
||||
var coder interface{ ExitCode() int }
|
||||
if !errors.As(err, &coder) {
|
||||
return false
|
||||
}
|
||||
code := coder.ExitCode()
|
||||
for _, c := range codes {
|
||||
if code == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wrap adds operation context to a tool error, leaving nil untouched.
|
||||
func wrap(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var execErr *exec.Error
|
||||
if errors.As(err, &execErr) {
|
||||
return fmt.Errorf("keyring: %s: %q not found (install the OS keyring tool): %w", op, execErr.Name, err)
|
||||
}
|
||||
return fmt.Errorf("keyring: %s: %w", op, err)
|
||||
}
|
||||
|
||||
func validate(service, account string) error {
|
||||
if strings.TrimSpace(service) == "" {
|
||||
return errors.New("keyring: service is required")
|
||||
}
|
||||
if strings.TrimSpace(account) == "" {
|
||||
return errors.New("keyring: account is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package keyring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeExit is a not-found exit error: it satisfies the ExitCode() seam used by
|
||||
// isNotFound without spawning a real process.
|
||||
type fakeExit struct{ code int }
|
||||
|
||||
func (e fakeExit) Error() string { return "exit status" }
|
||||
func (e fakeExit) ExitCode() int { return e.code }
|
||||
|
||||
// fakeKeyring is an in-memory simulation of the OS tools driven through the
|
||||
// runner seam. It records the last stdin so tests can assert the secret never
|
||||
// travels via argv on Linux.
|
||||
type fakeKeyring struct {
|
||||
goos string
|
||||
data map[string]string
|
||||
lastStdin string
|
||||
lastArgs []string
|
||||
}
|
||||
|
||||
func newFake(goos string) *fakeKeyring {
|
||||
return &fakeKeyring{goos: goos, data: map[string]string{}}
|
||||
}
|
||||
|
||||
func (f *fakeKeyring) keyring() *Keyring { return &Keyring{run: f.run, goos: f.goos} }
|
||||
|
||||
func flagValue(args []string, flag string) string {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attrValue(args []string, attr string) string {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == attr {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func key(service, account string) string { return service + "\x00" + account }
|
||||
|
||||
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
|
||||
f.lastStdin = string(stdin)
|
||||
f.lastArgs = append([]string{name}, args...)
|
||||
if len(args) == 0 {
|
||||
return nil, fakeExit{1}
|
||||
}
|
||||
switch f.goos {
|
||||
case "darwin":
|
||||
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
|
||||
switch args[0] {
|
||||
case "add-generic-password":
|
||||
f.data[key(svc, acct)] = flagValue(args, "-w")
|
||||
return nil, nil
|
||||
case "find-generic-password":
|
||||
if v, ok := f.data[key(svc, acct)]; ok {
|
||||
return []byte(v + "\n"), nil // security prints a trailing newline
|
||||
}
|
||||
return nil, fakeExit{44}
|
||||
case "delete-generic-password":
|
||||
if _, ok := f.data[key(svc, acct)]; ok {
|
||||
delete(f.data, key(svc, acct))
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fakeExit{44}
|
||||
}
|
||||
case "linux":
|
||||
svc, acct := attrValue(args, "service"), attrValue(args, "account")
|
||||
switch args[0] {
|
||||
case "store":
|
||||
f.data[key(svc, acct)] = string(stdin)
|
||||
return nil, nil
|
||||
case "lookup":
|
||||
if v, ok := f.data[key(svc, acct)]; ok {
|
||||
return []byte(v), nil // secret-tool prints no trailing newline
|
||||
}
|
||||
return nil, fakeExit{1}
|
||||
case "clear":
|
||||
delete(f.data, key(svc, acct))
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, fakeExit{1}
|
||||
}
|
||||
|
||||
func TestKeyringGetSurfacesNonNotFoundError(t *testing.T) {
|
||||
// On macOS only exit 44 (errSecItemNotFound) means "no entry"; any other
|
||||
// non-zero exit is a real failure that must surface, not be masked as absent.
|
||||
k := &Keyring{
|
||||
goos: "darwin",
|
||||
run: func(_ context.Context, _ string, _ []byte, _ ...string) ([]byte, error) {
|
||||
return nil, fakeExit{1}
|
||||
},
|
||||
}
|
||||
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
|
||||
t.Fatalf("a non-44 exit must surface as an error, got ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRoundTripDarwin(t *testing.T) {
|
||||
k := newFake("darwin").keyring()
|
||||
if err := k.Set("zero", "tokens", "blob-AAA"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
got, ok, err := k.Get("zero", "tokens")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got != "blob-AAA" {
|
||||
t.Fatalf("Get = %q, want blob-AAA", got)
|
||||
}
|
||||
existed, err := k.Delete("zero", "tokens")
|
||||
if err != nil || !existed {
|
||||
t.Fatalf("Delete: existed=%v err=%v", existed, err)
|
||||
}
|
||||
if _, ok, _ := k.Get("zero", "tokens"); ok {
|
||||
t.Fatal("token should be gone after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
|
||||
f := newFake("linux")
|
||||
k := f.keyring()
|
||||
if err := k.Set("zero", "tokens", "blob-BBB"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
// The secret must travel via stdin, never the argument vector.
|
||||
if f.lastStdin != "blob-BBB" {
|
||||
t.Fatalf("secret not sent via stdin: stdin=%q", f.lastStdin)
|
||||
}
|
||||
for _, a := range f.lastArgs {
|
||||
if strings.Contains(a, "blob-BBB") {
|
||||
t.Fatalf("secret leaked into argv: %v", f.lastArgs)
|
||||
}
|
||||
}
|
||||
got, ok, err := k.Get("zero", "tokens")
|
||||
if err != nil || !ok || got != "blob-BBB" {
|
||||
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
existed, err := k.Delete("zero", "tokens")
|
||||
if err != nil || !existed {
|
||||
t.Fatalf("Delete: existed=%v err=%v", existed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringGetMissingIsNotError(t *testing.T) {
|
||||
for _, goos := range []string{"darwin", "linux"} {
|
||||
k := newFake(goos).keyring()
|
||||
if _, ok, err := k.Get("zero", "absent"); err != nil || ok {
|
||||
t.Fatalf("[%s] Get(absent) = ok=%v err=%v, want false/nil", goos, ok, err)
|
||||
}
|
||||
if existed, err := k.Delete("zero", "absent"); err != nil || existed {
|
||||
t.Fatalf("[%s] Delete(absent) = existed=%v err=%v, want false/nil", goos, existed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringUnsupportedPlatform(t *testing.T) {
|
||||
k := &Keyring{run: newFake("windows").run, goos: "windows"}
|
||||
if k.Available() {
|
||||
t.Fatal("windows should report unavailable")
|
||||
}
|
||||
if err := k.Set("zero", "tokens", "x"); err == nil {
|
||||
t.Fatal("Set on unsupported platform should error")
|
||||
}
|
||||
if _, _, err := k.Get("zero", "tokens"); err == nil {
|
||||
t.Fatal("Get on unsupported platform should error")
|
||||
}
|
||||
if _, err := k.Delete("zero", "tokens"); err == nil {
|
||||
t.Fatal("Delete on unsupported platform should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringValidation(t *testing.T) {
|
||||
k := newFake("darwin").keyring()
|
||||
if err := k.Set("", "a", "s"); err == nil {
|
||||
t.Fatal("empty service should error")
|
||||
}
|
||||
if err := k.Set("svc", "", "s"); err == nil {
|
||||
t.Fatal("empty account should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyringMissingBinaryError(t *testing.T) {
|
||||
// A missing tool surfaces as a wrapped, descriptive error (not not-found).
|
||||
k := &Keyring{goos: "linux", run: func(context.Context, string, []byte, ...string) ([]byte, error) {
|
||||
return nil, &exec.Error{Name: "secret-tool", Err: exec.ErrNotFound}
|
||||
}}
|
||||
if err := k.Set("zero", "tokens", "x"); err == nil || !strings.Contains(err.Error(), "secret-tool") {
|
||||
t.Fatalf("missing-binary Set error = %v, want mention of secret-tool", err)
|
||||
}
|
||||
// A missing binary on Get must not be misread as not-found.
|
||||
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
|
||||
t.Fatalf("missing-binary Get = ok=%v err=%v, want error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailable(t *testing.T) {
|
||||
if !(newFake("darwin").keyring().Available()) {
|
||||
t.Fatal("darwin should be available")
|
||||
}
|
||||
if !(newFake("linux").keyring().Available()) {
|
||||
t.Fatal("linux should be available")
|
||||
}
|
||||
}
|
||||
+79
-187
@@ -2,9 +2,6 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,20 +11,56 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
// ServerAuthOAuth is the value of an MCP server's auth field that selects the
|
||||
// OAuth 2.0 + PKCE authorization-code flow.
|
||||
const ServerAuthOAuth = "oauth"
|
||||
|
||||
const (
|
||||
wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
defaultLoginTimeout = 3 * time.Minute
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier, the minimum
|
||||
// length permitted by the PKCE spec while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
)
|
||||
const defaultLoginTimeout = 3 * time.Minute
|
||||
|
||||
// MCP OAuth delegates its transport/identity-agnostic engine to internal/oauth
|
||||
// (PKCE, RFC 8414 discovery, authorize-URL build, token exchange/refresh) so the
|
||||
// two share one implementation. MCP keeps its own LoginOptions/Login
|
||||
// orchestration, OAuthConfig/StoredToken types, loopback handling, CLI, and
|
||||
// on-disk token format — all behavior-preserving. The shared engine also adds an
|
||||
// https-only token-endpoint guard (loopback exempt), a hardening MCP inherits.
|
||||
|
||||
// pkceToParams converts the shared PKCE pair to MCP's local type.
|
||||
func pkceToParams(p oauth.PKCE) pkceParams {
|
||||
return pkceParams{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// pkceToOAuth converts MCP's local PKCE type back to the shared type.
|
||||
func pkceToOAuth(p pkceParams) oauth.PKCE {
|
||||
return oauth.PKCE{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
|
||||
}
|
||||
|
||||
// configFor builds the shared oauth.Config from MCP's OAuthConfig + resolved
|
||||
// endpoints for a flow.
|
||||
func configFor(cfg OAuthConfig, metadata authServerMetadata) oauth.Config {
|
||||
return oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
AuthorizationEndpoint: metadata.AuthorizationEndpoint,
|
||||
TokenEndpoint: metadata.TokenEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// tokenToStored converts a shared oauth.Token to MCP's StoredToken (MCP does not
|
||||
// use the Account field).
|
||||
func tokenToStored(t oauth.Token) StoredToken {
|
||||
return StoredToken{
|
||||
AccessToken: t.AccessToken,
|
||||
RefreshToken: t.RefreshToken,
|
||||
TokenType: t.TokenType,
|
||||
Scopes: t.Scopes,
|
||||
ExpiresAt: t.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
|
||||
// Endpoints may be discovered from the server's metadata document; explicit
|
||||
@@ -84,34 +117,20 @@ type authorizationFlow struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// discoverAuthorizationServer fetches the OAuth 2.0 authorization server
|
||||
// metadata document published at the well-known path under the given base URL.
|
||||
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
|
||||
// at the well-known path under baseURL, via the shared engine.
|
||||
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return authServerMetadata{}, fmt.Errorf("authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata authServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
||||
return authServerMetadata{}, fmt.Errorf("decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
return authServerMetadata{
|
||||
Issuer: meta.Issuer,
|
||||
AuthorizationEndpoint: meta.AuthorizationEndpoint,
|
||||
TokenEndpoint: meta.TokenEndpoint,
|
||||
RegistrationEndpoint: meta.RegistrationEndpoint,
|
||||
ScopesSupported: meta.ScopesSupported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveAuthorizationServer discovers metadata and applies explicit config
|
||||
@@ -149,44 +168,23 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge.
|
||||
// newPKCE generates a high-entropy code verifier and its S256 challenge via the
|
||||
// shared engine.
|
||||
func newPKCE() (pkceParams, error) {
|
||||
verifierRaw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(verifierRaw); err != nil {
|
||||
return pkceParams{}, fmt.Errorf("generate PKCE verifier: %w", err)
|
||||
p, err := oauth.NewPKCE()
|
||||
if err != nil {
|
||||
return pkceParams{}, err
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(verifierRaw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return pkceParams{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
|
||||
return pkceToParams(p), nil
|
||||
}
|
||||
|
||||
func newState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
return oauth.NewState()
|
||||
}
|
||||
|
||||
// authorizationURL builds the authorization request URL.
|
||||
// authorizationURL builds the authorization request URL via the shared engine.
|
||||
func (flow *authorizationFlow) authorizationURL(redirectURI string) (string, error) {
|
||||
parsed, err := url.Parse(flow.metadata.AuthorizationEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", flow.config.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", flow.state)
|
||||
query.Set("code_challenge", flow.pkce.Challenge)
|
||||
query.Set("code_challenge_method", flow.pkce.Method)
|
||||
if len(flow.config.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(flow.config.Scopes, " "))
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
return oauth.BuildAuthorizationURL(configFor(flow.config, flow.metadata), pkceToOAuth(flow.pkce), flow.state, redirectURI, nil)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization
|
||||
@@ -209,117 +207,30 @@ func (flow *authorizationFlow) parseCallback(values url.Values) (string, error)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
// exchangeCode swaps an authorization code + PKCE verifier for tokens via the
|
||||
// shared engine.
|
||||
func (flow *authorizationFlow) exchangeCode(ctx context.Context, code string, redirectURI string) (StoredToken, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", flow.config.ClientID)
|
||||
form.Set("code_verifier", flow.pkce.Verifier)
|
||||
if secret := strings.TrimSpace(flow.config.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
token, err := oauth.ExchangeCode(ctx, flow.httpClient, configFor(flow.config, flow.metadata), code, flow.pkce.Verifier, redirectURI, flow.now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return postTokenRequest(ctx, flow.httpClient, flow.metadata.TokenEndpoint, form, StoredToken{Scopes: flow.config.Scopes}, flow.now)
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token. A
|
||||
// response that omits a new refresh token preserves the previous one.
|
||||
// refreshAccessToken exchanges a refresh token for a fresh access token via the
|
||||
// shared engine. A response that omits a new refresh token preserves the
|
||||
// previous one.
|
||||
func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfig, current StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
refresh := strings.TrimSpace(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return StoredToken{}, errors.New("no refresh token available")
|
||||
}
|
||||
tokenEndpoint := strings.TrimSpace(cfg.TokenEndpoint)
|
||||
if tokenEndpoint == "" {
|
||||
return StoredToken{}, errors.New("no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := strings.TrimSpace(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
refreshed, err := postTokenRequest(ctx, client, tokenEndpoint, form, StoredToken{Scopes: current.Scopes, RefreshToken: refresh}, now)
|
||||
token, err := oauth.Refresh(ctx, client, oauth.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Scopes: cfg.Scopes,
|
||||
TokenEndpoint: cfg.TokenEndpoint,
|
||||
}, oauth.Token{AccessToken: current.AccessToken, RefreshToken: current.RefreshToken, TokenType: current.TokenType, Scopes: current.Scopes, ExpiresAt: current.ExpiresAt}, now)
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// tokenResponse is the JSON returned by the token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// postTokenRequest performs a token endpoint POST and maps the response onto a
|
||||
// StoredToken. The base token supplies values to preserve (e.g. an existing
|
||||
// refresh token or scope set) when the response omits them.
|
||||
func postTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base StoredToken, now func() time.Time) (StoredToken, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return StoredToken{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return StoredToken{}, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
// A malformed body on an error status is reported as a status error below.
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return StoredToken{}, fmt.Errorf("token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if strings.TrimSpace(parsed.AccessToken) == "" {
|
||||
return StoredToken{}, errors.New("token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if strings.TrimSpace(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if strings.TrimSpace(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := strings.TrimSpace(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
return tokenToStored(token), nil
|
||||
}
|
||||
|
||||
// registerClient performs dynamic client registration against the registration
|
||||
@@ -505,22 +416,3 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
|
||||
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
// RFC 8414: the well-known segment is inserted between the host and any issuer
|
||||
// path component, so a path-based issuer (https://host/tenant-a) is probed at
|
||||
// https://host/.well-known/oauth-authorization-server/tenant-a — not at the
|
||||
// host root (which would break tenant-scoped discovery).
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
+132
-173
@@ -6,17 +6,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenStoreSchemaVersion = 1
|
||||
tokenStoreLockTimeout = 5 * time.Second
|
||||
tokenStoreLockRetry = 10 * time.Millisecond
|
||||
)
|
||||
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
|
||||
// retained so migration can recognize a file it understands.
|
||||
const tokenStoreSchemaVersion = 1
|
||||
|
||||
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
|
||||
// for a single MCP server. The token fields are sensitive: they are tagged so
|
||||
@@ -42,29 +40,37 @@ type TokenStatus struct {
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// TokenStoreOptions configures where OAuth tokens are persisted. When FilePath
|
||||
// is empty the path is resolved under the user config dir, mirroring how MCP
|
||||
// permissions are stored.
|
||||
// TokenStoreOptions configures the unified token store backing MCP OAuth tokens.
|
||||
// FilePath overrides the store path (default: the shared oauth store path).
|
||||
// LegacyPath overrides the pre-unification file migrated on construction; when
|
||||
// empty it defaults to the conventional mcp-oauth-tokens.json only for the
|
||||
// default (FilePath-unset) store, so an explicit FilePath never triggers an
|
||||
// unexpected migration from the real user config.
|
||||
type TokenStoreOptions struct {
|
||||
FilePath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
FilePath string
|
||||
LegacyPath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// TokenStore persists OAuth tokens per MCP server in a 0600 JSON file.
|
||||
// TokenStore persists MCP OAuth tokens in the unified oauth store
|
||||
// (internal/oauth) under the "mcp:" namespace, sharing one file with provider
|
||||
// logins. On construction it transparently and non-destructively migrates a
|
||||
// legacy mcp-oauth-tokens.json into the unified store.
|
||||
type TokenStore struct {
|
||||
filePath string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
store *oauth.Store
|
||||
}
|
||||
|
||||
// tokenFile is the legacy on-disk format, retained only to read a
|
||||
// pre-unification mcp-oauth-tokens.json during migration.
|
||||
type tokenFile struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Tokens map[string]StoredToken `json:"tokens"`
|
||||
}
|
||||
|
||||
// ResolveTokenStorePath determines the on-disk location for OAuth tokens,
|
||||
// honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
|
||||
// ResolveTokenStorePath determines the on-disk location of the LEGACY OAuth token
|
||||
// file, honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
|
||||
// It is used to locate a pre-unification file for migration.
|
||||
func ResolveTokenStorePath(env map[string]string) (string, error) {
|
||||
override := strings.TrimSpace(envValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"))
|
||||
if override != "" {
|
||||
@@ -95,215 +101,168 @@ func ResolveTokenStorePath(env map[string]string) (string, error) {
|
||||
return filepath.Join(configHome, "zero", "mcp-oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewTokenStore builds a file-backed token store.
|
||||
// NewTokenStore builds the unified-store-backed token store and runs a one-time
|
||||
// migration from a legacy file when applicable.
|
||||
func NewTokenStore(options TokenStoreOptions) (*TokenStore, error) {
|
||||
filePath := options.FilePath
|
||||
var err error
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath, err = ResolveTokenStorePath(options.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &TokenStore{filePath: filepath.Clean(filePath), now: now}, nil
|
||||
unified, err := oauth.NewStore(oauth.StoreOptions{
|
||||
FilePath: options.FilePath,
|
||||
Env: options.Env,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &TokenStore{store: unified}
|
||||
|
||||
legacyPath := strings.TrimSpace(options.LegacyPath)
|
||||
if legacyPath == "" && strings.TrimSpace(options.FilePath) == "" {
|
||||
// Default (production) construction: migrate from the conventional path.
|
||||
legacyPath, err = ResolveTokenStorePath(options.Env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if legacyPath != "" {
|
||||
if err := store.migrateLegacy(legacyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// FilePath returns the resolved token store path.
|
||||
// FilePath returns the resolved unified store path.
|
||||
func (store *TokenStore) FilePath() string {
|
||||
return store.filePath
|
||||
return store.store.FilePath()
|
||||
}
|
||||
|
||||
// Save persists the token for a server, replacing any existing entry.
|
||||
func (store *TokenStore) Save(serverName string, token StoredToken) error {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
unlock, err := store.lockStateFile()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Tokens[serverName] = token
|
||||
return store.writeState(state)
|
||||
return store.store.Save(key, storedToOAuth(token))
|
||||
}
|
||||
|
||||
// Load returns the stored token for a server. The second return value is false
|
||||
// when no token has been stored for the server.
|
||||
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return StoredToken{}, false, err
|
||||
}
|
||||
token, ok := state.Tokens[serverName]
|
||||
return token, ok, nil
|
||||
token, ok, err := store.store.Load(key)
|
||||
if err != nil || !ok {
|
||||
return StoredToken{}, ok, err
|
||||
}
|
||||
return tokenToStored(token), true, nil
|
||||
}
|
||||
|
||||
// Delete removes the stored token for a server. It reports whether an entry was
|
||||
// present before deletion.
|
||||
func (store *TokenStore) Delete(serverName string) (bool, error) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
unlock, err := store.lockStateFile()
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, ok := state.Tokens[serverName]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(state.Tokens, serverName)
|
||||
if err := store.writeState(state); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
return store.store.Delete(key)
|
||||
}
|
||||
|
||||
// Status returns a redaction-safe summary of every stored token, sorted by
|
||||
// Status returns a redaction-safe summary of every stored MCP token, sorted by
|
||||
// server name. It never includes the token material.
|
||||
func (store *TokenStore) Status() ([]TokenStatus, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
state, err := store.readState()
|
||||
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(state.Tokens))
|
||||
for name := range state.Tokens {
|
||||
names = append(names, name)
|
||||
out := make([]TokenStatus, 0, len(statuses))
|
||||
for _, s := range statuses {
|
||||
out = append(out, TokenStatus{
|
||||
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
|
||||
HasToken: s.HasToken,
|
||||
HasRefreshToken: s.HasRefreshToken,
|
||||
TokenType: s.TokenType,
|
||||
Scopes: s.Scopes,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
Expired: s.Expired,
|
||||
})
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
now := store.now()
|
||||
statuses := make([]TokenStatus, 0, len(names))
|
||||
for _, name := range names {
|
||||
token := state.Tokens[name]
|
||||
status := TokenStatus{
|
||||
ServerName: name,
|
||||
HasToken: strings.TrimSpace(token.AccessToken) != "",
|
||||
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
|
||||
TokenType: token.TokenType,
|
||||
Scopes: token.Scopes,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
}
|
||||
if !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now) {
|
||||
status.Expired = true
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
return statuses, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (store *TokenStore) readState() (tokenFile, error) {
|
||||
data, err := os.ReadFile(store.filePath)
|
||||
// migrateLegacy imports tokens from a legacy mcp-oauth-tokens.json into the
|
||||
// unified store (under "mcp:" keys), then renames the legacy file to a
|
||||
// ".migrated" backup. It is non-destructive and idempotent: a newer unified
|
||||
// entry is never overwritten, a missing/unreadable/foreign-schema legacy file is
|
||||
// left untouched, and the rename ensures it is imported at most once.
|
||||
func (store *TokenStore) migrateLegacy(legacyPath string) error {
|
||||
legacyPath = filepath.Clean(legacyPath)
|
||||
// FilePath() is an absolute path; resolve a relative LegacyPath so the
|
||||
// same-file guard can't be bypassed by a relative spelling of the same file.
|
||||
if !filepath.IsAbs(legacyPath) {
|
||||
abs, err := filepath.Abs(legacyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacyPath = filepath.Clean(abs)
|
||||
}
|
||||
if legacyPath == store.store.FilePath() {
|
||||
return nil // legacy and unified resolve to the same file; nothing to migrate
|
||||
}
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return emptyTokenFile(), nil
|
||||
return nil
|
||||
}
|
||||
return tokenFile{}, err
|
||||
}
|
||||
var state tokenFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
|
||||
}
|
||||
if state.SchemaVersion != tokenStoreSchemaVersion {
|
||||
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: unsupported schemaVersion", store.filePath)
|
||||
}
|
||||
if state.Tokens == nil {
|
||||
state.Tokens = map[string]StoredToken{}
|
||||
}
|
||||
for serverName := range state.Tokens {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (store *TokenStore) writeState(state tokenFile) error {
|
||||
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
var legacy tokenFile
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return nil // unreadable legacy file: leave it in place, don't block startup
|
||||
}
|
||||
tempPath := fmt.Sprintf("%s.tmp-%d-%d", store.filePath, os.Getpid(), store.now().UnixNano())
|
||||
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
if legacy.SchemaVersion != tokenStoreSchemaVersion {
|
||||
return nil // unknown legacy schema: leave it untouched
|
||||
}
|
||||
if err := os.Rename(tempPath, store.filePath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *TokenStore) lockStateFile() (func(), error) {
|
||||
lockPath := store.filePath + ".lockfile"
|
||||
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(tokenStoreLockTimeout)
|
||||
for {
|
||||
locked, err := tryLockPermissionFile(file)
|
||||
for serverName, token := range legacy.Tokens {
|
||||
key, err := mcpKey(serverName)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
continue // a name that cannot form a valid unified key is skipped
|
||||
}
|
||||
if locked {
|
||||
return func() {
|
||||
_ = unlockPermissionFile(file)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
if _, ok, loadErr := store.store.Load(key); loadErr == nil && ok {
|
||||
continue // a unified entry already exists; never overwrite
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("timed out waiting for MCP OAuth token lock at %s", lockPath)
|
||||
if err := store.store.Save(key, storedToOAuth(token)); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(tokenStoreLockRetry)
|
||||
}
|
||||
return os.Rename(legacyPath, legacyPath+".migrated")
|
||||
}
|
||||
|
||||
func emptyTokenFile() tokenFile {
|
||||
return tokenFile{
|
||||
SchemaVersion: tokenStoreSchemaVersion,
|
||||
Tokens: map[string]StoredToken{},
|
||||
// mcpKey builds and validates the unified store key for an MCP server token.
|
||||
func mcpKey(serverName string) (string, error) {
|
||||
if err := ValidateServerName(serverName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
|
||||
if err := oauth.ValidateKey(key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
|
||||
// inverse of tokenToStored). MCP never sets the oauth Account field.
|
||||
func storedToOAuth(s StoredToken) oauth.Token {
|
||||
return oauth.Token{
|
||||
AccessToken: s.AccessToken,
|
||||
RefreshToken: s.RefreshToken,
|
||||
TokenType: s.TokenType,
|
||||
Scopes: s.Scopes,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,101 @@ func TestTokenStoreStatusReportsPresenceWithoutToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreMigratesLegacyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
|
||||
unified := filepath.Join(dir, "oauth-tokens.json")
|
||||
legacyData := `{"schemaVersion":1,"tokens":{"demo":{"access_token":"a","refresh_token":"r","token_type":"Bearer"}}}`
|
||||
if err := os.WriteFile(legacy, []byte(legacyData), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore: %v", err)
|
||||
}
|
||||
|
||||
tok, ok, err := store.Load("demo")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("migrated token not loadable: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if tok.AccessToken != "a" || tok.RefreshToken != "r" {
|
||||
t.Fatalf("migrated token = %#v", tok)
|
||||
}
|
||||
|
||||
// The unified file keys the token under the mcp: namespace.
|
||||
raw, err := os.ReadFile(unified)
|
||||
if err != nil {
|
||||
t.Fatalf("read unified: %v", err)
|
||||
}
|
||||
if !contains(string(raw), "mcp:demo") {
|
||||
t.Fatalf("unified file should key under mcp: namespace:\n%s", raw)
|
||||
}
|
||||
|
||||
// The legacy file is renamed to a .migrated backup (non-destructive, one-time).
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy file should be renamed away; stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacy + ".migrated"); err != nil {
|
||||
t.Fatalf("legacy backup missing: %v", err)
|
||||
}
|
||||
|
||||
// Idempotent: a second construction (legacy now absent) keeps the token.
|
||||
store2, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenStore#2: %v", err)
|
||||
}
|
||||
if _, ok, _ := store2.Load("demo"); !ok {
|
||||
t.Fatal("token lost after second construction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreMigrationPreservesNewerUnified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
|
||||
unified := filepath.Join(dir, "oauth-tokens.json")
|
||||
if err := os.WriteFile(legacy, []byte(`{"schemaVersion":1,"tokens":{"demo":{"access_token":"OLD"}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pre-seed the unified store with a newer token (no migration: FilePath set, no LegacyPath).
|
||||
pre, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pre.Save("demo", StoredToken{AccessToken: "NEW"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Migrating must not overwrite the newer unified entry.
|
||||
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, _, _ := store.Load("demo")
|
||||
if tok.AccessToken != "NEW" {
|
||||
t.Fatalf("migration overwrote a newer token: %q", tok.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenStoreNamespacedFromProvider(t *testing.T) {
|
||||
// An MCP token and a provider login of the same name coexist in one file.
|
||||
dir := t.TempDir()
|
||||
unified := filepath.Join(dir, "oauth-tokens.json")
|
||||
mcpStore, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mcpStore.Save("shared", StoredToken{AccessToken: "mcp-token"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses, err := mcpStore.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].ServerName != "shared" {
|
||||
t.Fatalf("status = %#v, want one entry for 'shared'", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenStorePathUsesXDG(t *testing.T) {
|
||||
// Use a real temp dir so the base is absolute on every OS (a literal
|
||||
// "/tmp/..." isn't absolute on Windows, where ResolveTokenStorePath would
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package notify
|
||||
|
||||
import "strings"
|
||||
|
||||
// Webhook delivery is configured entirely from the environment. A webhook URL
|
||||
// typically embeds a secret token, so sourcing it from the environment keeps it
|
||||
// out of any on-disk config file. The sink is strictly opt-in: with
|
||||
// EnvWebhookURL unset the wiring helper attaches nothing and the notifier
|
||||
// behaves exactly as before.
|
||||
const (
|
||||
// EnvWebhookURL holds the destination webhook/Slack URL. Empty disables it.
|
||||
EnvWebhookURL = "ZERO_NOTIFY_WEBHOOK_URL"
|
||||
// EnvWebhookSummary is an optional one-line run summary attached to every
|
||||
// payload (for example "nightly audit run").
|
||||
EnvWebhookSummary = "ZERO_NOTIFY_WEBHOOK_SUMMARY"
|
||||
)
|
||||
|
||||
// MaybeAddWebhookSink attaches a webhook sink to n when a webhook URL is present
|
||||
// in the environment, and is otherwise a no-op. env resolves an environment
|
||||
// variable (pass os.Getenv); logf records one redacted line per failed delivery
|
||||
// (pass nil to stay silent — for example a TUI that owns the screen). It is safe
|
||||
// to call unconditionally: configuration alone decides whether the sink exists.
|
||||
//
|
||||
// The attached sink is still subject to the notifier's Mode/focus policy, so a
|
||||
// webhook only delivers when notifications are enabled (for example
|
||||
// `--notify both`), matching the rest of the notification surface.
|
||||
func MaybeAddWebhookSink(n *Notifier, env func(string) string, logf func(format string, args ...any)) {
|
||||
if n == nil || env == nil {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(env(EnvWebhookURL))
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
n.AddSink(NewWebhookSink(WebhookConfig{
|
||||
URL: url,
|
||||
Summary: strings.TrimSpace(env(EnvWebhookSummary)),
|
||||
Logf: logf,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// envFunc builds a deterministic env resolver from a map for the wiring tests.
|
||||
func envFunc(values map[string]string) func(string) string {
|
||||
return func(key string) string { return values[key] }
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkAttachesAndDelivers(t *testing.T) {
|
||||
var hits int32
|
||||
var gotSummary string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
var payload webhookPayload
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&payload)
|
||||
gotSummary = payload.Summary
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
MaybeAddWebhookSink(n, envFunc(map[string]string{
|
||||
EnvWebhookURL: server.URL,
|
||||
EnvWebhookSummary: "nightly audit",
|
||||
}), nil)
|
||||
|
||||
if got := len(n.sinks); got != 1 {
|
||||
t.Fatalf("expected 1 sink attached, got %d", got)
|
||||
}
|
||||
|
||||
n.Notify(Completion, "Zero: ready")
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("webhook hit %d times, want 1", got)
|
||||
}
|
||||
if gotSummary != "nightly audit" {
|
||||
t.Fatalf("summary = %q, want %q", gotSummary, "nightly audit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkNoopWhenURLBlank(t *testing.T) {
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
|
||||
// Unset.
|
||||
MaybeAddWebhookSink(n, envFunc(nil), nil)
|
||||
// Set but blank / whitespace only.
|
||||
MaybeAddWebhookSink(n, envFunc(map[string]string{EnvWebhookURL: " "}), nil)
|
||||
|
||||
if got := len(n.sinks); got != 0 {
|
||||
t.Fatalf("expected no sink attached, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeAddWebhookSinkNilGuards(t *testing.T) {
|
||||
// Must not panic on a nil notifier or nil env resolver.
|
||||
MaybeAddWebhookSink(nil, envFunc(map[string]string{EnvWebhookURL: "https://example.test"}), nil)
|
||||
|
||||
n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways})
|
||||
MaybeAddWebhookSink(n, nil, nil)
|
||||
if got := len(n.sinks); got != 0 {
|
||||
t.Fatalf("nil env must attach nothing, got %d sinks", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
|
||||
// defaultDeviceCodeLifetime bounds a device authorization that omits expires_in,
|
||||
// so the expiry gate in PollDeviceToken is always active (fail closed).
|
||||
const defaultDeviceCodeLifetime = 10 * time.Minute
|
||||
|
||||
// DeviceAuth is the result of an RFC 8628 device-authorization request.
|
||||
type DeviceAuth struct {
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
VerificationURI string
|
||||
VerificationURIComplete string
|
||||
Interval time.Duration
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type deviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Interval int64 `json:"interval"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// RequestDeviceCode performs the RFC 8628 device-authorization request. This is
|
||||
// the headless/SSH login path (no browser, no loopback).
|
||||
func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error) {
|
||||
endpoint := trimmed(cfg.DeviceAuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: provider has no device authorization endpoint")
|
||||
}
|
||||
if err := validateTokenEndpoint(endpoint); err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
// Confidential clients must authenticate on the device endpoint too,
|
||||
// consistent with the token poll (pollDeviceOnce).
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return DeviceAuth{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed deviceAuthResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization error %q", parsed.Error)
|
||||
}
|
||||
return DeviceAuth{}, fmt.Errorf("oauth: device authorization returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.DeviceCode) == "" || trimmed(parsed.UserCode) == "" {
|
||||
return DeviceAuth{}, errors.New("oauth: device authorization response missing device_code/user_code")
|
||||
}
|
||||
interval := time.Duration(parsed.Interval) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second // RFC 8628 default
|
||||
}
|
||||
auth := DeviceAuth{
|
||||
DeviceCode: parsed.DeviceCode,
|
||||
UserCode: parsed.UserCode,
|
||||
VerificationURI: parsed.VerificationURI,
|
||||
VerificationURIComplete: parsed.VerificationURIComplete,
|
||||
Interval: interval,
|
||||
}
|
||||
lifetime := time.Duration(parsed.ExpiresIn) * time.Second
|
||||
if lifetime <= 0 {
|
||||
// Fail closed: a response without expires_in must still get a bounded
|
||||
// lifetime so the poll loop's expiry gate stays active.
|
||||
lifetime = defaultDeviceCodeLifetime
|
||||
}
|
||||
auth.ExpiresAt = now().Add(lifetime)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint for the device grant until the user
|
||||
// approves, the device code expires, ctx is done, or a terminal error occurs. It
|
||||
// honors authorization_pending (keep waiting), slow_down (increase the
|
||||
// interval), and expired_token.
|
||||
func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
interval := auth.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
for {
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Token{}, fmt.Errorf("oauth: device authorization canceled: %w", ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
// Re-check expiry after sleeping: slow_down (or a long interval) can push
|
||||
// the next poll past ExpiresAt, and we must not poll after the deadline.
|
||||
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
}
|
||||
token, err := pollDeviceOnce(ctx, client, cfg, auth.DeviceCode, now)
|
||||
switch {
|
||||
case err == nil:
|
||||
return token, nil
|
||||
case errors.Is(err, ErrAuthorizationPending):
|
||||
// keep waiting at the current interval
|
||||
case errors.Is(err, ErrSlowDown):
|
||||
interval += 5 * time.Second // RFC 8628: add 5s on slow_down
|
||||
default:
|
||||
return Token{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pollDeviceOnce makes a single device-token poll, translating the RFC 8628
|
||||
// error codes into ErrAuthorizationPending / ErrSlowDown / terminal errors.
|
||||
func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, deviceCode string, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", deviceGrantType)
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(cfg.TokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: device token poll failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && trimmed(parsed.AccessToken) != "" {
|
||||
token := Token{Scopes: cfg.Scopes}
|
||||
token.AccessToken = parsed.AccessToken
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
token.TokenType = parsed.TokenType
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
switch parsed.Error {
|
||||
case "authorization_pending":
|
||||
return Token{}, ErrAuthorizationPending
|
||||
case "slow_down":
|
||||
return Token{}, ErrSlowDown
|
||||
case "expired_token":
|
||||
return Token{}, errors.New("oauth: device code expired before authorization")
|
||||
case "access_denied":
|
||||
return Token{}, errors.New("oauth: authorization denied by the user")
|
||||
case "":
|
||||
return Token{}, fmt.Errorf("oauth: device token poll returned HTTP %d", response.StatusCode)
|
||||
default:
|
||||
return Token{}, fmt.Errorf("oauth: device token error %q", parsed.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
|
||||
var gotSecret string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
gotSecret = r.Form.Get("client_secret")
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x","expires_in":600,"interval":5}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
// A confidential client must authenticate on the device endpoint too.
|
||||
_, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if gotSecret != "shh" {
|
||||
t.Fatalf("client_secret = %q, want shh", gotSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL, Scopes: []string{"a"}}
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.DeviceCode != "dc" || auth.UserCode != "WXYZ-1234" {
|
||||
t.Fatalf("device auth = %+v", auth)
|
||||
}
|
||||
if auth.Interval != 3*time.Second {
|
||||
t.Fatalf("interval = %v, want 3s", auth.Interval)
|
||||
}
|
||||
if auth.VerificationURIComplete == "" {
|
||||
t.Fatal("missing verification_uri_complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceCodeDefaultsInterval(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
auth, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceCode: %v", err)
|
||||
}
|
||||
if auth.Interval != 5*time.Second {
|
||||
t.Fatalf("default interval = %v, want 5s", auth.Interval)
|
||||
}
|
||||
// A response without expires_in must still get a bounded expiry (fail closed),
|
||||
// so the poll loop's expiry gate stays active.
|
||||
if auth.ExpiresAt.IsZero() {
|
||||
t.Fatal("missing expires_in must default to a bounded ExpiresAt, got zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenHonorsPendingThenSucceeds(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
n := calls.Add(1)
|
||||
if n < 3 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":3600}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Small interval + future expiry so the loop is fast.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatalf("polled %d times, want 3 (2 pending + 1 success)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenSlowDown(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"slow_down"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"access_token":"at"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
// Expiry well beyond the slow_down back-off (+5s) so the success path never
|
||||
// depends on polling after the device code has expired.
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(30 * time.Second)}
|
||||
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PollDeviceToken (slow_down): %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenExpired(t *testing.T) {
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: "https://unused/token"}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: time.Millisecond, ExpiresAt: time.Now().Add(-time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), http.DefaultClient, cfg, auth, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollDeviceTokenAccessDenied(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"access_denied"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
|
||||
_, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "denied") {
|
||||
t.Fatalf("err = %v, want access denied", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
|
||||
|
||||
// ServerMetadata is the subset of an RFC 8414 authorization-server metadata
|
||||
// document the engine consumes.
|
||||
type ServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
}
|
||||
|
||||
// DiscoverAuthorizationServer fetches the RFC 8414 metadata document at the
|
||||
// well-known path under baseURL.
|
||||
func DiscoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (ServerMetadata, error) {
|
||||
metadataURL, err := joinWellKnown(baseURL)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
return fetchMetadata(ctx, client, metadataURL)
|
||||
}
|
||||
|
||||
// fetchMetadata GETs and decodes an authorization-server metadata document at an
|
||||
// already-resolved URL (no well-known recomputation), so callers can also use it
|
||||
// for the OIDC openid-configuration path.
|
||||
func fetchMetadata(ctx context.Context, client *http.Client, metadataURL string) (ServerMetadata, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: fetch authorization server metadata: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: authorization server metadata returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var metadata ServerMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, tokenResponseLimit)).Decode(&metadata); err != nil {
|
||||
return ServerMetadata{}, fmt.Errorf("oauth: decode authorization server metadata: %w", err)
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// joinWellKnown builds the RFC 8414 metadata URL, inserting the well-known
|
||||
// segment between host and any issuer path component (so a path-based issuer is
|
||||
// probed tenant-scoped, not at the host root).
|
||||
func joinWellKnown(baseURL string) (string, error) {
|
||||
parsed, err := url.Parse(trimmed(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("oauth: invalid base URL for metadata discovery: %q", baseURL)
|
||||
}
|
||||
issuerPath := strings.Trim(parsed.Path, "/")
|
||||
parsed.Path = wellKnownAuthServerPath
|
||||
if issuerPath != "" {
|
||||
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return parsed.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretBytes is the AES-256 key length kept in the per-user secret file.
|
||||
const secretBytes = 32
|
||||
|
||||
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
|
||||
// per-user random secret persisted (0600) beside the token file. The on-disk
|
||||
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
|
||||
// detection, so a corrupted/forged file fails closed on open. This is the
|
||||
// opt-in "encrypted-file" storage backend; the default backend writes the
|
||||
// 0600 plaintext JSON unchanged.
|
||||
type aesGCMCrypter struct {
|
||||
secretPath string
|
||||
}
|
||||
|
||||
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
|
||||
return &aesGCMCrypter{secretPath: secretPath}
|
||||
}
|
||||
|
||||
// aead loads (or, when create is set, generates) the secret and returns the GCM
|
||||
// AEAD. open passes create=false so a missing secret is a hard error rather than
|
||||
// silently minting a new key that could never decrypt the existing file.
|
||||
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
|
||||
secret, err := loadOrCreateSecret(c.secretPath, create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: build cipher: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
|
||||
// on first use.
|
||||
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
|
||||
// a short blob, or a failed authentication tag (tampering / wrong key).
|
||||
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
|
||||
gcm, err := c.aead(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < gcm.NonceSize() {
|
||||
return nil, errors.New("oauth: encrypted token file is too short")
|
||||
}
|
||||
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
|
||||
// the file is absent, it generates a random secret and creates the file
|
||||
// exclusively (0600). A wrong-sized existing secret fails closed (corruption).
|
||||
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
|
||||
if data, err := readSecretFile(path); err == nil {
|
||||
return data, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if !create {
|
||||
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
|
||||
}
|
||||
secret := make([]byte, secretBytes)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Create exclusively: an os.Rename publish would clobber on POSIX, so two
|
||||
// concurrent first-run processes could each generate a secret and the loser
|
||||
// would silently orphan the tokens it encrypts. O_EXCL lets exactly one
|
||||
// process win; everyone else adopts the winner's on-disk secret.
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
// The winner may not have finished writing yet, so a read here can see a
|
||||
// short/absent file. Retry briefly so concurrent first-run invocations
|
||||
// converge on the winner's secret instead of failing transiently.
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
secret, rerr := readSecretFile(path)
|
||||
if rerr == nil {
|
||||
return secret, nil
|
||||
}
|
||||
lastErr = rerr
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: create token secret: %w", err)
|
||||
}
|
||||
if _, werr := f.Write(secret); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// readSecretFile reads and validates the secret at path, returning a wrapped
|
||||
// os.ErrNotExist when it is absent so callers can branch on creation.
|
||||
func readSecretFile(path string) ([]byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: read token secret: %w", err)
|
||||
}
|
||||
if len(data) != secretBytes {
|
||||
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
const n = 16
|
||||
secrets := make([][]byte, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
secrets[i], errs[i] = loadOrCreateSecret(path, true)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
// Exactly one creator wins; every racer must converge on the same on-disk
|
||||
// secret rather than orphaning its own (the O_EXCL + bounded-retry path).
|
||||
for i := 0; i < n; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("goroutine %d: %v", i, errs[i])
|
||||
}
|
||||
if !bytes.Equal(secrets[i], secrets[0]) {
|
||||
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
c := newAESGCMCrypter(secretPath)
|
||||
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
|
||||
blob, err := c.seal(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
if bytes.Contains(blob, plaintext) {
|
||||
t.Fatal("sealed blob must not contain the plaintext")
|
||||
}
|
||||
got, err := c.open(blob)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Fatalf("round-trip mismatch: %q", got)
|
||||
}
|
||||
// Tamper: flipping any byte breaks the GCM tag.
|
||||
tampered := append([]byte(nil), blob...)
|
||||
tampered[len(tampered)-1] ^= 0xff
|
||||
if _, err := c.open(tampered); err == nil {
|
||||
t.Fatal("open must reject a tampered blob (GCM auth)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateSecret(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tok.json.secret")
|
||||
// Missing + create=false => fail closed (can't decrypt without the secret).
|
||||
if _, err := loadOrCreateSecret(path, false); err == nil {
|
||||
t.Fatal("missing secret with create=false must error")
|
||||
}
|
||||
secret, err := loadOrCreateSecret(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if len(secret) != secretBytes {
|
||||
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
|
||||
}
|
||||
// Stable across reads.
|
||||
again, err := loadOrCreateSecret(path, false)
|
||||
if err != nil || !bytes.Equal(again, secret) {
|
||||
t.Fatalf("secret not stable: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat secret: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("secret file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
// Wrong-sized secret fails closed.
|
||||
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt secret: %v", err)
|
||||
}
|
||||
if _, err := loadOrCreateSecret(path, true); err == nil {
|
||||
t.Fatal("wrong-sized secret must error")
|
||||
}
|
||||
}
|
||||
|
||||
func newEncryptedStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(encrypted): %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
// The unified Storage="encrypted-file" selector must encrypt at rest, the same as
|
||||
// the legacy Encrypted:true alias (the file/keyring/encrypted-file merge).
|
||||
func TestNewStoreEncryptedFileStorageSelector(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(encrypted-file): %v", err)
|
||||
}
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "super-secret-access"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
|
||||
t.Fatalf("Storage=encrypted-file did not encrypt at rest:\n%s", raw)
|
||||
}
|
||||
got, ok, err := s.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || got.AccessToken != "super-secret-access" {
|
||||
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedStoreRoundTripAndCiphertextOnDisk(t *testing.T) {
|
||||
s, path := newEncryptedStore(t)
|
||||
tok := Token{AccessToken: "super-secret-access", RefreshToken: "super-secret-refresh", Account: "me@x"}
|
||||
if err := s.Save(ProviderKey("demo"), tok); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
// On-disk file must be ciphertext: not valid JSON, no plaintext token.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
|
||||
t.Fatalf("token file is not encrypted at rest:\n%s", raw)
|
||||
}
|
||||
// The secret file exists beside it.
|
||||
if _, err := os.Stat(path + ".secret"); err != nil {
|
||||
t.Fatalf("secret file missing: %v", err)
|
||||
}
|
||||
// A fresh Store (same path) decrypts and reads it back.
|
||||
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore 2: %v", err)
|
||||
}
|
||||
got, ok, err := s2.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || got.AccessToken != "super-secret-access" || got.Account != "me@x" {
|
||||
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
// Delete + Status work through the encrypted backend too.
|
||||
statuses, err := s2.Status(KeyPrefixProvider)
|
||||
if err != nil || len(statuses) != 1 {
|
||||
t.Fatalf("Status = %+v err=%v", statuses, err)
|
||||
}
|
||||
if removed, err := s2.Delete(ProviderKey("demo")); err != nil || !removed {
|
||||
t.Fatalf("Delete = %v %v", removed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedStoreTamperFailsClosed(t *testing.T) {
|
||||
s, path := newEncryptedStore(t)
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("encrypted token file is empty")
|
||||
}
|
||||
raw[len(raw)-1] ^= 0xff
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("rewrite: %v", err)
|
||||
}
|
||||
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
|
||||
t.Fatal("a tampered encrypted store must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
|
||||
s, path := newEncryptedStore(t)
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := os.Remove(path + ".secret"); err != nil {
|
||||
t.Fatalf("remove secret: %v", err)
|
||||
}
|
||||
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
|
||||
t.Fatal("missing secret must fail closed, not return empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenResponseLimit = 1 << 20 // 1 MiB cap on token-endpoint bodies
|
||||
|
||||
// tokenResponse is the JSON returned by a token endpoint.
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL constructs the authorization request URL for an
|
||||
// authorization-code + PKCE flow. PKCE S256 is always included; a non-S256
|
||||
// method is refused (ErrPKCEDowngrade). extraParams (and cfg.ExtraAuthParams)
|
||||
// are appended last.
|
||||
func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, extraParams map[string]string) (string, error) {
|
||||
if pkce.Method != MethodS256 {
|
||||
return "", ErrPKCEDowngrade
|
||||
}
|
||||
endpoint := trimmed(cfg.AuthorizationEndpoint)
|
||||
if endpoint == "" {
|
||||
return "", errors.New("oauth: no authorization endpoint configured")
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", cfg.ClientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", pkce.Challenge)
|
||||
query.Set("code_challenge_method", pkce.Method)
|
||||
if len(cfg.Scopes) > 0 {
|
||||
query.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
// Extra params must never override the reserved OAuth/PKCE fields above
|
||||
// (e.g. forcing code_challenge_method=plain or rewriting state/redirect_uri),
|
||||
// which would break the flow's security guarantees.
|
||||
for k, v := range cfg.ExtraAuthParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
for k, v := range extraParams {
|
||||
if isReservedAuthParam(k) {
|
||||
continue
|
||||
}
|
||||
query.Set(k, v)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
// isReservedAuthParam reports whether a query key is one the engine controls and
|
||||
// must not be overridable by caller-supplied extra params.
|
||||
func isReservedAuthParam(key string) bool {
|
||||
switch key {
|
||||
case "response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
|
||||
// not https, unless it is a loopback host (mirrors oauth2-client.js
|
||||
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
parsed, err := url.Parse(trimmed(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
}
|
||||
if parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeCode swaps an authorization code + PKCE verifier for tokens.
|
||||
func ExchangeCode(ctx context.Context, client *http.Client, cfg Config, code, verifier, redirectURI string, now func() time.Time) (Token, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURI)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
form.Set("code_verifier", verifier)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, Token{Scopes: cfg.Scopes}, now)
|
||||
}
|
||||
|
||||
// Refresh exchanges a refresh token for a fresh access token. A response that
|
||||
// omits a new refresh token preserves the current one.
|
||||
func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token, now func() time.Time) (Token, error) {
|
||||
refresh := trimmed(current.RefreshToken)
|
||||
if refresh == "" {
|
||||
return Token{}, ErrNoRefreshToken
|
||||
}
|
||||
if trimmed(cfg.TokenEndpoint) == "" {
|
||||
return Token{}, errors.New("oauth: no token endpoint configured for refresh")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("client_id", cfg.ClientID)
|
||||
if secret := trimmed(cfg.ClientSecret); secret != "" {
|
||||
form.Set("client_secret", secret)
|
||||
}
|
||||
if len(cfg.Scopes) > 0 {
|
||||
form.Set("scope", strings.Join(cfg.Scopes, " "))
|
||||
}
|
||||
base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account}
|
||||
return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now)
|
||||
}
|
||||
|
||||
// PostToken performs a token-endpoint POST and maps the response onto a Token.
|
||||
// The https guard is applied first. The base token supplies values to preserve
|
||||
// (e.g. an existing refresh token or scopes) when the response omits them.
|
||||
// Error messages carry only the server's error/error_description — never the raw
|
||||
// body — so token material in an unexpected payload is not leaked.
|
||||
func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base Token, now func() time.Time) (Token, error) {
|
||||
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(tokenEndpoint), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: token request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
|
||||
var parsed tokenResponse
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &parsed)
|
||||
}
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if parsed.Error != "" {
|
||||
if parsed.ErrorDescription != "" {
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint error %q", parsed.Error)
|
||||
}
|
||||
return Token{}, fmt.Errorf("oauth: token endpoint returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
if trimmed(parsed.AccessToken) == "" {
|
||||
return Token{}, errors.New("oauth: token endpoint returned no access token")
|
||||
}
|
||||
|
||||
token := base
|
||||
token.AccessToken = parsed.AccessToken
|
||||
if trimmed(parsed.RefreshToken) != "" {
|
||||
token.RefreshToken = parsed.RefreshToken
|
||||
}
|
||||
if trimmed(parsed.TokenType) != "" {
|
||||
token.TokenType = parsed.TokenType
|
||||
}
|
||||
if parsed.ExpiresIn > 0 {
|
||||
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
|
||||
}
|
||||
if scope := trimmed(parsed.Scope); scope != "" {
|
||||
token.Scopes = strings.Fields(scope)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPKCE(t *testing.T) {
|
||||
p, err := NewPKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("NewPKCE: %v", err)
|
||||
}
|
||||
if p.Method != MethodS256 {
|
||||
t.Fatalf("method = %q, want S256", p.Method)
|
||||
}
|
||||
if len(p.Verifier) != 43 { // base64url(32 bytes) = 43 chars
|
||||
t.Fatalf("verifier length = %d, want 43", len(p.Verifier))
|
||||
}
|
||||
sum := sha256.Sum256([]byte(p.Verifier))
|
||||
want := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if p.Challenge != want {
|
||||
t.Fatalf("challenge = %q, want base64url(sha256(verifier))", p.Challenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStateUnique(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
s, err := NewState()
|
||||
if err != nil {
|
||||
t.Fatalf("NewState: %v", err)
|
||||
}
|
||||
if s == "" || seen[s] {
|
||||
t.Fatalf("state not unique/non-empty: %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURL(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "client-123",
|
||||
Scopes: []string{"read", "write"},
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
ExtraAuthParams: map[string]string{"login_hint": "[email protected]"},
|
||||
}
|
||||
pkce := PKCE{Verifier: "v", Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "state-xyz", "http://127.0.0.1:9/callback", map[string]string{"prompt": "login"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
u, _ := url.Parse(raw)
|
||||
q := u.Query()
|
||||
checks := map[string]string{
|
||||
"response_type": "code",
|
||||
"client_id": "client-123",
|
||||
"redirect_uri": "http://127.0.0.1:9/callback",
|
||||
"state": "state-xyz",
|
||||
"code_challenge": "chal",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "read write",
|
||||
"login_hint": "[email protected]",
|
||||
"prompt": "login",
|
||||
}
|
||||
for k, want := range checks {
|
||||
if got := q.Get(k); got != want {
|
||||
t.Errorf("query %q = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLIgnoresReservedExtras(t *testing.T) {
|
||||
cfg := Config{
|
||||
ClientID: "c",
|
||||
AuthorizationEndpoint: "https://auth.example.com/authorize",
|
||||
// A hostile config tries to weaken the flow via extra params.
|
||||
ExtraAuthParams: map[string]string{"state": "attacker", "code_challenge_method": "plain", "login_hint": "keep-me"},
|
||||
}
|
||||
pkce := PKCE{Challenge: "chal", Method: MethodS256}
|
||||
raw, err := BuildAuthorizationURL(cfg, pkce, "real-state", "http://127.0.0.1:9/cb", map[string]string{"redirect_uri": "http://evil/cb"})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildAuthorizationURL: %v", err)
|
||||
}
|
||||
parsed, _ := url.Parse(raw)
|
||||
q := parsed.Query()
|
||||
if q.Get("state") != "real-state" {
|
||||
t.Fatalf("state overridden: %q", q.Get("state"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("PKCE method downgraded: %q", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://127.0.0.1:9/cb" {
|
||||
t.Fatalf("redirect_uri overridden: %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("login_hint") != "keep-me" {
|
||||
t.Fatalf("non-reserved extra param should still apply: %q", q.Get("login_hint"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
|
||||
_, err := BuildAuthorizationURL(Config{AuthorizationEndpoint: "https://a/x"}, PKCE{Method: "plain"}, "s", "r", nil)
|
||||
if !errors.Is(err, ErrPKCEDowngrade) {
|
||||
t.Fatalf("err = %v, want ErrPKCEDowngrade", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
"http://127.0.0.1:1455/token": true, // loopback exempt
|
||||
"http://localhost:1455/token": true,
|
||||
"http://[::1]:1455/token": true,
|
||||
"http://auth.example.com/token": false, // non-https, non-loopback
|
||||
"ftp://auth.example.com/token": false,
|
||||
}
|
||||
for endpoint, ok := range cases {
|
||||
err := validateTokenEndpoint(endpoint)
|
||||
if ok && err != nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = %v, want nil", endpoint, err)
|
||||
}
|
||||
if !ok && err == nil {
|
||||
t.Errorf("validateTokenEndpoint(%q) = nil, want error", endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("grant_type") != "authorization_code" || r.FormValue("code_verifier") != "verifier" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600,"scope":"read write"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "verifier", "http://127.0.0.1/cb", func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeCode: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.TokenType != "Bearer" {
|
||||
t.Fatalf("token = %+v", tok)
|
||||
}
|
||||
if !tok.ExpiresAt.Equal(now.Add(time.Hour)) {
|
||||
t.Fatalf("expiresAt = %v, want now+1h", tok.ExpiresAt)
|
||||
}
|
||||
if strings.Join(tok.Scopes, " ") != "read write" {
|
||||
t.Fatalf("scopes = %v", tok.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeErrorRedactsBody(t *testing.T) {
|
||||
// The error body contains a stray access_token-looking field; it must NOT
|
||||
// surface in the returned error (only error/error_description do).
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"bad code","access_token":"SECRET-LEAK"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
_, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "v", "http://127.0.0.1/cb", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_grant") || !strings.Contains(err.Error(), "bad code") {
|
||||
t.Fatalf("error should carry error/description, got %q", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "SECRET-LEAK") {
|
||||
t.Fatalf("error leaked raw body token material: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostTokenRefusesInsecureEndpoint(t *testing.T) {
|
||||
_, err := PostToken(context.Background(), http.DefaultClient, "http://auth.example.com/token", url.Values{}, Token{}, nil)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNoToken(t *testing.T) {
|
||||
_, err := Refresh(context.Background(), http.DefaultClient, Config{TokenEndpoint: "https://a/token"}, Token{}, nil)
|
||||
if !errors.Is(err, ErrNoRefreshToken) {
|
||||
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreservesRefreshTokenWhenOmitted(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no refresh_token in response
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
|
||||
tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "new-at" || tok.RefreshToken != "keep-me" {
|
||||
t.Fatalf("refresh should preserve old refresh token: %+v", tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fileLockTimeout = 5 * time.Second
|
||||
fileLockStaleAfter = 30 * time.Second
|
||||
)
|
||||
|
||||
var lockSeq atomic.Uint64
|
||||
|
||||
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
|
||||
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
|
||||
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
|
||||
// store). Release is ownership-aware: it removes the lock only if it still holds
|
||||
// our token, so a stale-broken holder cannot delete a newer holder's lock.
|
||||
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
|
||||
deadline := now().Add(fileLockTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
// A partial/failed write would leave a lock file without our token, so
|
||||
// the releaser below could never delete it — stranding the lock for
|
||||
// other processes. Fail closed: remove the file and surface the error.
|
||||
if _, werr := f.WriteString(token); werr != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
|
||||
}
|
||||
if cerr := f.Close(); cerr != nil {
|
||||
_ = os.Remove(lockPath)
|
||||
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
|
||||
}
|
||||
var released bool
|
||||
return func() {
|
||||
if released {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
|
||||
}
|
||||
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
|
||||
stale, _ := os.ReadFile(lockPath)
|
||||
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
|
||||
_ = os.Remove(lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if now().After(deadline) {
|
||||
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
|
||||
// redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port,
|
||||
// verify the CSRF state, hand back the code, then close.
|
||||
type LoopbackListener struct {
|
||||
listener net.Listener
|
||||
state string
|
||||
result chan callbackResult
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
// NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and
|
||||
// begins serving. state is the CSRF value the callback must echo back. Call
|
||||
// RedirectURI to build the redirect_uri, then Wait for the code. Always Close.
|
||||
func NewLoopbackListener(state string) (*LoopbackListener, error) {
|
||||
// Refuse an empty state: the callback check compares the query's state to this
|
||||
// value, so an empty state would match a callback carrying no state at all,
|
||||
// defeating CSRF protection (fail closed).
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oauth: start loopback redirect listener: %w", err)
|
||||
}
|
||||
l := &LoopbackListener{
|
||||
listener: ln,
|
||||
state: state,
|
||||
result: make(chan callbackResult, 1),
|
||||
}
|
||||
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
|
||||
go func() { _ = l.server.Serve(ln) }()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.
|
||||
func (l *LoopbackListener) RedirectURI() string {
|
||||
return fmt.Sprintf("http://%s/callback", l.listener.Addr().String())
|
||||
}
|
||||
|
||||
func (l *LoopbackListener) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
code, err := parseCallback(r.URL.Query(), l.state)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
} else {
|
||||
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
|
||||
}
|
||||
select {
|
||||
case l.result <- callbackResult{code: code, err: err}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the callback arrives or ctx is done, returning the
|
||||
// authorization code.
|
||||
func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
|
||||
select {
|
||||
case res := <-l.result:
|
||||
return res.code, res.err
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("oauth: timed out waiting for authorization callback: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts the listener down (bounded), idempotent.
|
||||
func (l *LoopbackListener) Close() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = l.server.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
// parseCallback validates the redirect query and returns the authorization code,
|
||||
// rejecting a mismatched state (CSRF) and surfacing provider errors.
|
||||
func parseCallback(values url.Values, wantState string) (string, error) {
|
||||
if got := values.Get("state"); got != wantState {
|
||||
return "", ErrStateMismatch
|
||||
}
|
||||
if providerErr := strings.TrimSpace(values.Get("error")); providerErr != "" {
|
||||
if desc := strings.TrimSpace(values.Get("error_description")); desc != "" {
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q: %s", providerErr, desc)
|
||||
}
|
||||
return "", fmt.Errorf("oauth: authorization server returned error %q", providerErr)
|
||||
}
|
||||
code := strings.TrimSpace(values.Get("code"))
|
||||
if code == "" {
|
||||
return "", errors.New("oauth: callback missing authorization code")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoopbackCapturesCode(t *testing.T) {
|
||||
l, err := NewLoopbackListener("the-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
if !strings.HasPrefix(l.RedirectURI(), "http://127.0.0.1:") {
|
||||
t.Fatalf("redirect URI not loopback: %q", l.RedirectURI())
|
||||
}
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=auth-code&state=the-state")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
code, err := l.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Wait: %v", err)
|
||||
}
|
||||
if code != "auth-code" {
|
||||
t.Fatalf("code = %q, want auth-code", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoopbackListenerRejectsEmptyState(t *testing.T) {
|
||||
if _, err := NewLoopbackListener(" "); err == nil {
|
||||
t.Fatal("an empty CSRF state must be rejected (fail closed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackRejectsStateMismatch(t *testing.T) {
|
||||
l, err := NewLoopbackListener("expected-state")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?code=x&state=WRONG")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if !errors.Is(err, ErrStateMismatch) {
|
||||
t.Fatalf("Wait err = %v, want ErrStateMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackTimesOut(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
if _, err := l.Wait(ctx); err == nil {
|
||||
t.Fatal("Wait should time out cleanly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackProviderError(t *testing.T) {
|
||||
l, err := NewLoopbackListener("s")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoopbackListener: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
_, _ = http.Get(l.RedirectURI() + "?error=access_denied&error_description=nope&state=s")
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = l.Wait(ctx)
|
||||
if err == nil || !strings.Contains(err.Error(), "access_denied") {
|
||||
t.Fatalf("Wait err = %v, want provider error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoToken reports that no token is stored for a key. Callers (e.g. a request
|
||||
// path that prefers OAuth but can fall back to an API key) match it with
|
||||
// errors.Is to distinguish "not logged in" from a real refresh failure.
|
||||
var ErrNoToken = errors.New("oauth: no stored token")
|
||||
|
||||
// defaultRefreshBuffer refreshes a token this long before its hard expiry.
|
||||
const defaultRefreshBuffer = 60 * time.Second
|
||||
|
||||
const oidcWellKnownPath = "/.well-known/openid-configuration"
|
||||
|
||||
// Manager ties the token store, provider registry, and HTTP client together to
|
||||
// run logins and serve fresh access tokens. It is the high-level entrypoint the
|
||||
// CLI and request paths use.
|
||||
type Manager struct {
|
||||
store *Store
|
||||
registry *Registry
|
||||
client *http.Client
|
||||
env map[string]string
|
||||
now func() time.Time
|
||||
buffer time.Duration
|
||||
out io.Writer
|
||||
// openBrowser is invoked with the authorization URL for loopback logins.
|
||||
// Tests inject a function that drives the loopback redirect.
|
||||
openBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
Store *Store
|
||||
Registry *Registry
|
||||
HTTPClient *http.Client
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
RefreshBuffer time.Duration
|
||||
Out io.Writer
|
||||
OpenBrowser func(authURL string) error
|
||||
}
|
||||
|
||||
// NewManager builds a Manager, filling defaults.
|
||||
func NewManager(opts ManagerOptions) (*Manager, error) {
|
||||
if opts.Store == nil {
|
||||
return nil, fmt.Errorf("oauth: manager requires a store")
|
||||
}
|
||||
registry := opts.Registry
|
||||
if registry == nil {
|
||||
registry = NewRegistry()
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
buffer := opts.RefreshBuffer
|
||||
if buffer <= 0 {
|
||||
buffer = defaultRefreshBuffer
|
||||
}
|
||||
out := opts.Out
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
open := opts.OpenBrowser
|
||||
if open == nil {
|
||||
open = func(string) error { return nil }
|
||||
}
|
||||
return &Manager{
|
||||
store: opts.Store, registry: registry, client: client,
|
||||
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoginOptions configures a single provider login.
|
||||
type LoginOptions struct {
|
||||
Provider string
|
||||
Device bool // force device-code flow
|
||||
ExtraScopes []string // appended to the provider's scopes
|
||||
Timeout time.Duration // bounds the whole interactive login
|
||||
}
|
||||
|
||||
// Login runs the provider login (loopback by default, device-code when
|
||||
// requested or when the provider only supports device), stores the token under
|
||||
// "provider:<name>", and returns a redaction-safe status.
|
||||
func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error) {
|
||||
cfg, flow, err := m.registry.ResolveConfig(opts.Provider, m.env)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if len(opts.ExtraScopes) > 0 {
|
||||
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
loginCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, err = m.resolveEndpoints(loginCtx, cfg)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
useDevice := opts.Device || flow == FlowDevice
|
||||
var token Token
|
||||
if useDevice {
|
||||
token, err = m.loginDevice(loginCtx, cfg)
|
||||
} else {
|
||||
token, err = m.loginLoopback(loginCtx, cfg)
|
||||
}
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
|
||||
key := ProviderKey(opts.Provider)
|
||||
if err := m.store.Save(key, token); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return m.statusFor(key)
|
||||
}
|
||||
|
||||
// PrepareDeviceLogin resolves the provider config and requests an RFC 8628
|
||||
// device code, returning the user-facing DeviceAuth (verification URI + user
|
||||
// code) and the resolved config. It is split from the token poll
|
||||
// (CompleteDeviceLogin) so a UI can display the code to the user while waiting
|
||||
// for authorization. The CLI's monolithic Login(Device:true) is unaffected.
|
||||
func (m *Manager) PrepareDeviceLogin(ctx context.Context, opts LoginOptions) (DeviceAuth, Config, error) {
|
||||
cfg, _, err := m.registry.ResolveConfig(opts.Provider, m.env)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, Config{}, err
|
||||
}
|
||||
if len(opts.ExtraScopes) > 0 {
|
||||
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
|
||||
}
|
||||
// Self-bound the network prepare (endpoint discovery + device-code request) the
|
||||
// same way Login does, so a caller that hands in an unbounded context still gets
|
||||
// the timeout guarantee instead of a hang.
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
prepCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cfg, err = m.resolveEndpoints(prepCtx, cfg)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, Config{}, err
|
||||
}
|
||||
auth, err := RequestDeviceCode(prepCtx, m.client, cfg, m.now)
|
||||
if err != nil {
|
||||
return DeviceAuth{}, Config{}, err
|
||||
}
|
||||
return auth, cfg, nil
|
||||
}
|
||||
|
||||
// CompleteDeviceLogin polls for the token authorized via PrepareDeviceLogin and
|
||||
// stores it under "provider:<name>", returning a redaction-safe status. Pass the
|
||||
// cfg and auth returned by PrepareDeviceLogin.
|
||||
func (m *Manager) CompleteDeviceLogin(ctx context.Context, provider string, cfg Config, auth DeviceAuth) (Status, error) {
|
||||
// Bound the poll by the device code's own expiry so an unbounded caller context
|
||||
// cannot leave an in-flight request hanging past the code's lifetime. PollDeviceToken
|
||||
// re-checks ExpiresAt between polls; the deadline also caps each individual request.
|
||||
if !auth.ExpiresAt.IsZero() {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithDeadline(ctx, auth.ExpiresAt)
|
||||
defer cancel()
|
||||
}
|
||||
token, err := PollDeviceToken(ctx, m.client, cfg, auth, m.now)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
key := ProviderKey(provider)
|
||||
if err := m.store.Save(key, token); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return m.statusFor(key)
|
||||
}
|
||||
|
||||
// resolveEndpoints fills missing authorize/token/device endpoints from issuer
|
||||
// discovery (RFC 8414 then OIDC), leaving any explicitly-pinned endpoint intact.
|
||||
func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, error) {
|
||||
if trimmed(cfg.IssuerURL) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if cfg.AuthorizationEndpoint != "" && cfg.TokenEndpoint != "" && cfg.DeviceAuthorizationEndpoint != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
// Discovery is best-effort: a failure is non-fatal because pinned endpoints
|
||||
// may already be sufficient for the chosen flow. Only merge on success (and
|
||||
// never return from the error branch, which would trip the nilerr linter).
|
||||
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
|
||||
if cfg.AuthorizationEndpoint == "" {
|
||||
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
|
||||
}
|
||||
if cfg.TokenEndpoint == "" {
|
||||
cfg.TokenEndpoint = meta.TokenEndpoint
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint == "" {
|
||||
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// discover tries the OAuth (RFC 8414) well-known path, then the OIDC
|
||||
// openid-configuration path (some issuers publish only the latter).
|
||||
func (m *Manager) discover(ctx context.Context, issuer string) (ServerMetadata, error) {
|
||||
meta, err := DiscoverAuthorizationServer(ctx, m.client, issuer)
|
||||
if err == nil && (meta.AuthorizationEndpoint != "" || meta.TokenEndpoint != "") {
|
||||
return meta, nil
|
||||
}
|
||||
oidcURL := strings.TrimRight(strings.TrimSpace(issuer), "/") + oidcWellKnownPath
|
||||
return fetchMetadata(ctx, m.client, oidcURL)
|
||||
}
|
||||
|
||||
func (m *Manager) loginLoopback(ctx context.Context, cfg Config) (Token, error) {
|
||||
if trimmed(cfg.AuthorizationEndpoint) == "" {
|
||||
return Token{}, fmt.Errorf("oauth: no authorization endpoint (set the authorize URL or a discoverable issuer)")
|
||||
}
|
||||
state, err := NewState()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
pkce, err := NewPKCE()
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
listener, err := NewLoopbackListener(state)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
defer listener.Close()
|
||||
redirectURI := listener.RedirectURI()
|
||||
authURL, err := BuildAuthorizationURL(cfg, pkce, state, redirectURI, nil)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
fmt.Fprintf(m.out, "Open this URL to authorize:\n %s\n", authURL)
|
||||
if err := m.openBrowser(authURL); err != nil {
|
||||
return Token{}, fmt.Errorf("oauth: open authorization URL: %w", err)
|
||||
}
|
||||
code, err := listener.Wait(ctx)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
return ExchangeCode(ctx, m.client, cfg, code, pkce.Verifier, redirectURI, m.now)
|
||||
}
|
||||
|
||||
func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
|
||||
auth, err := RequestDeviceCode(ctx, m.client, cfg, m.now)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
target := auth.VerificationURIComplete
|
||||
if target == "" {
|
||||
target = auth.VerificationURI
|
||||
}
|
||||
// user_code is meant to be displayed to the user; it is not a secret.
|
||||
fmt.Fprintf(m.out, "To authorize, visit:\n %s\nand enter code: %s\n", target, auth.UserCode)
|
||||
return PollDeviceToken(ctx, m.client, cfg, auth, m.now)
|
||||
}
|
||||
|
||||
// GetFresh returns a valid access token for key, refreshing on-demand if the
|
||||
// stored token is expired or within the refresh buffer. Mirrors
|
||||
// checkAndRefreshOAuthTokenIfNeeded.
|
||||
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A still-valid token is returned without resolving the provider config, so a
|
||||
// readable login never depends on config that is only needed to refresh (e.g.
|
||||
// an opt-in preset client_id). Only an actual refresh resolves endpoints.
|
||||
if !token.NeedsRefresh(m.now(), m.buffer) {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
cfg, err := m.resolveConfigForKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
// Handle401 forces a refresh after an upstream 401, returning the new access
|
||||
// token. Mirrors handleOAuth401Error.
|
||||
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := m.resolveConfigForKey(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.refreshAndSave(ctx, key, cfg, token)
|
||||
}
|
||||
|
||||
func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, current Token) (string, error) {
|
||||
refreshed, err := Refresh(ctx, m.client, cfg, current, m.now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := m.store.Save(key, refreshed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return refreshed.AccessToken, nil
|
||||
}
|
||||
|
||||
// loadToken loads a stored token for key without resolving any provider config,
|
||||
// so reading a still-valid login never depends on refresh-only configuration.
|
||||
func (m *Manager) loadToken(key string) (Token, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
token, ok, err := m.store.Load(key)
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Token{}, fmt.Errorf("%w for %q", ErrNoToken, key)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// resolveConfigForKey resolves the provider OAuth config for a provider-token key.
|
||||
// It is only needed to refresh a token (the endpoints + client identity), not to
|
||||
// read one.
|
||||
func (m *Manager) resolveConfigForKey(ctx context.Context, key string) (Config, error) {
|
||||
name := strings.TrimPrefix(key, KeyPrefixProvider)
|
||||
if name == key {
|
||||
return Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
|
||||
}
|
||||
cfg, _, err := m.registry.ResolveConfig(name, m.env)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
// Fill any missing token/authorize/device endpoints from issuer discovery so a
|
||||
// provider configured with only ZERO_OAUTH_<NAME>_ISSUER_URL can still refresh
|
||||
// (refreshAndSave requires the token endpoint).
|
||||
cfg, err = m.resolveEndpoints(ctx, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Logout removes a provider's stored token, reporting whether one was present.
|
||||
func (m *Manager) Logout(name string) (bool, error) {
|
||||
return m.store.Delete(ProviderKey(name))
|
||||
}
|
||||
|
||||
// StatusAll returns the status of every provider login.
|
||||
func (m *Manager) StatusAll() ([]Status, error) {
|
||||
return m.store.Status(KeyPrefixProvider)
|
||||
}
|
||||
|
||||
func (m *Manager) statusFor(key string) (Status, error) {
|
||||
statuses, err := m.store.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
for _, st := range statuses {
|
||||
if st.Key == key {
|
||||
return st, nil
|
||||
}
|
||||
}
|
||||
return Status{Key: key}, nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeProvider is an httptest server that plays a token + device endpoint.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T, tokenJSON string) *fakeProvider {
|
||||
t.Helper()
|
||||
fp := &fakeProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fp.tokenHits.Add(1)
|
||||
_, _ = io.WriteString(w, tokenJSON)
|
||||
})
|
||||
mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Approve immediately on poll; short interval so the test is fast.
|
||||
_, _ = io.WriteString(w, `{"device_code":"dc","user_code":"U-1","verification_uri":"https://example/dev","expires_in":600,"interval":1}`)
|
||||
})
|
||||
fp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(fp.server.Close)
|
||||
return fp
|
||||
}
|
||||
|
||||
func managerFor(t *testing.T, env map[string]string, openBrowser func(string) error) *Manager {
|
||||
t.Helper()
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: env,
|
||||
OpenBrowser: openBrowser,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestManagerLoginLoopback(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
// The fake browser drives the loopback redirect with the captured state.
|
||||
openBrowser := func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
redirect := u.Query().Get("redirect_uri")
|
||||
state := u.Query().Get("state")
|
||||
_, err = http.Get(redirect + "?code=the-code&state=" + url.QueryEscape(state))
|
||||
return err
|
||||
}
|
||||
m := managerFor(t, env, openBrowser)
|
||||
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demo"})
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if !status.HasToken || status.Key != ProviderKey("demo") {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
// The token is persisted and GetFresh returns it without a needless refresh.
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "at" {
|
||||
t.Fatalf("access = %q, want at", access)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLoginDevice(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"dev-at","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMODEV_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMODEV_TOKEN_URL": fp.server.URL + "/token",
|
||||
"ZERO_OAUTH_DEMODEV_DEVICE_URL": fp.server.URL + "/device",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
status, err := m.Login(context.Background(), LoginOptions{Provider: "demodev", Device: true})
|
||||
if err != nil {
|
||||
t.Fatalf("device Login: %v", err)
|
||||
}
|
||||
if !status.HasToken {
|
||||
t.Fatalf("device status = %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPrepareAndCompleteDeviceLogin(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"two-phase-at","token_type":"Bearer","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO2_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO2_TOKEN_URL": fp.server.URL + "/token",
|
||||
"ZERO_OAUTH_DEMO2_DEVICE_URL": fp.server.URL + "/device",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
|
||||
// Phase 1: request the device code (what the UI displays to the user).
|
||||
auth, cfg, err := m.PrepareDeviceLogin(context.Background(), LoginOptions{Provider: "demo2"})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareDeviceLogin: %v", err)
|
||||
}
|
||||
if auth.UserCode != "U-1" || auth.VerificationURI == "" {
|
||||
t.Fatalf("device auth missing user code/uri: %+v", auth)
|
||||
}
|
||||
|
||||
// Phase 2: poll for the token and persist it.
|
||||
status, err := m.CompleteDeviceLogin(context.Background(), "demo2", cfg, auth)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteDeviceLogin: %v", err)
|
||||
}
|
||||
if !status.HasToken || status.Key != ProviderKey("demo2") {
|
||||
t.Fatalf("status = %+v", status)
|
||||
}
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo2"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "two-phase-at" {
|
||||
t.Fatalf("access = %q, want two-phase-at", access)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider configured with only an issuer URL (no TOKEN_URL) must still be
|
||||
// refreshable: GetFresh resolves the token endpoint via discovery before
|
||||
// refreshing. Guards resolveConfigForKey calling resolveEndpoints.
|
||||
func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
|
||||
var tokenHits int32
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"`+server.URL+`/authorize"}`)
|
||||
})
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&tokenHits, 1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"refreshed-at","token_type":"Bearer","expires_in":3600}`)
|
||||
})
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_ISSUERONLY_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_ISSUERONLY_ISSUER_URL": server.URL, // no TOKEN_URL → discovered
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
if err := m.store.Save(ProviderKey("issueronly"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
got, err := m.GetFresh(context.Background(), ProviderKey("issueronly"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh (issuer-only) must discover the token endpoint and refresh: %v", err)
|
||||
}
|
||||
if got != "refreshed-at" {
|
||||
t.Fatalf("access = %q, want refreshed-at", got)
|
||||
}
|
||||
if atomic.LoadInt32(&tokenHits) == 0 {
|
||||
t.Fatal("token endpoint never hit — discovery/refresh did not run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Seed an expired token with a refresh token.
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "fresh-at" {
|
||||
t.Fatalf("access = %q, want fresh-at (refreshed)", access)
|
||||
}
|
||||
if fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("token endpoint hit %d times, want 1 refresh", fp.tokenHits.Load())
|
||||
}
|
||||
// The refreshed token is persisted.
|
||||
stored, _, _ := m.store.Load(ProviderKey("demo"))
|
||||
if stored.AccessToken != "fresh-at" {
|
||||
t.Fatalf("stored token not updated: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshSkipsValidToken(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"should-not-be-used"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("GetFresh: %v", err)
|
||||
}
|
||||
if access != "valid" || fp.tokenHits.Load() != 0 {
|
||||
t.Fatalf("a valid token must not be refreshed (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerHandle401ForcesRefresh(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"after-401"}`)
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
|
||||
}
|
||||
m := managerFor(t, env, nil)
|
||||
// Token is still valid by clock, but Handle401 forces a refresh anyway.
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
access, err := m.Handle401(context.Background(), ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Handle401: %v", err)
|
||||
}
|
||||
if access != "after-401" || fp.tokenHits.Load() != 1 {
|
||||
t.Fatalf("Handle401 must force a refresh (access=%q hits=%d)", access, fp.tokenHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerLogout(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "a"})
|
||||
removed, err := m.Logout("demo")
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Logout = %v %v", removed, err)
|
||||
}
|
||||
if removed2, _ := m.Logout("demo"); removed2 {
|
||||
t.Fatal("second logout should report nothing removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the
|
||||
// authorization-code + PKCE flow that internal/mcp already uses for MCP servers
|
||||
// into a transport/identity-agnostic core, and adds the pieces provider login
|
||||
// needs: a device-authorization (RFC 8628) grant, a provider registry, a
|
||||
// namespaced + refreshing token store, and a proactive refresh scheduler.
|
||||
//
|
||||
// Security invariants (enforced throughout, never relaxed):
|
||||
// - PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
|
||||
// - A per-flow CSRF state is generated and verified on the callback.
|
||||
// - The loopback callback server binds 127.0.0.1 only, on an OS-assigned port,
|
||||
// serves a single request, then closes.
|
||||
// - The token endpoint must be https (loopback exempt) before any credential is
|
||||
// sent — see validateTokenEndpoint.
|
||||
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
|
||||
// - Token files are 0600, base-confined, and file-locked.
|
||||
//
|
||||
// This package holds NO vendor secrets: provider client IDs and endpoints come
|
||||
// from config/env by default, so no third-party OAuth client identity is used
|
||||
// unless the operator opts in. A small set of built-in presets (e.g. xAI's public
|
||||
// client) exists for convenience but is OFF by default and only consulted when
|
||||
// ZERO_OAUTH_ALLOW_PRESETS is set; env always overrides a preset.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token holds the credentials issued by an authorization server. The token
|
||||
// fields are sensitive: callers must never log them, and the store persists them
|
||||
// 0600. It mirrors the on-disk shape internal/mcp uses so the two stay
|
||||
// format-compatible.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
// Account is an optional non-secret identifier (email / account id) shown in
|
||||
// status output; never a credential.
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// Expired reports whether the token has an expiry that is at or before now.
|
||||
// A zero ExpiresAt means "no known expiry" and is treated as not expired.
|
||||
func (t Token) Expired(now time.Time) bool {
|
||||
return !t.ExpiresAt.IsZero() && !t.ExpiresAt.After(now)
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the token is expired or falls within buffer of
|
||||
// expiry (so a proactive/on-demand refresh should run). A token with no expiry
|
||||
// never needs a refresh on a timer.
|
||||
func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool {
|
||||
if t.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return !t.ExpiresAt.After(now.Add(buffer))
|
||||
}
|
||||
|
||||
// Config describes how to talk to one authorization server for a flow.
|
||||
// Endpoints may be discovered (RFC 8414) and overridden here.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
|
||||
AuthorizationEndpoint string
|
||||
TokenEndpoint string
|
||||
DeviceAuthorizationEndpoint string
|
||||
RegistrationEndpoint string
|
||||
// IssuerURL is the base for metadata discovery when endpoints are not set.
|
||||
IssuerURL string
|
||||
|
||||
// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
|
||||
ExtraAuthParams map[string]string
|
||||
}
|
||||
|
||||
// Errors returned by the engine. Callers can match these with errors.Is.
|
||||
var (
|
||||
// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
|
||||
ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
|
||||
// ErrStateMismatch is returned when the callback state does not match (CSRF).
|
||||
ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
|
||||
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
|
||||
// non-https, non-loopback endpoint.
|
||||
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
|
||||
// ErrNoRefreshToken is returned when a refresh is attempted without one.
|
||||
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
|
||||
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
|
||||
ErrAuthorizationPending = errors.New("oauth: authorization pending")
|
||||
// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
|
||||
ErrSlowDown = errors.New("oauth: slow down")
|
||||
)
|
||||
|
||||
// trimmed is a tiny helper used across the package.
|
||||
func trimmed(s string) string { return strings.TrimSpace(s) }
|
||||
@@ -0,0 +1,49 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// pkceVerifierBytes yields a 43-character base64url verifier — the minimum
|
||||
// length the PKCE spec permits while remaining high-entropy.
|
||||
pkceVerifierBytes = 32
|
||||
stateBytes = 32
|
||||
// MethodS256 is the only code-challenge method this engine accepts.
|
||||
MethodS256 = "S256"
|
||||
)
|
||||
|
||||
// PKCE is a verifier/challenge pair for an authorization-code flow. The method
|
||||
// is always S256 (the engine refuses "plain").
|
||||
type PKCE struct {
|
||||
Verifier string
|
||||
Challenge string
|
||||
Method string
|
||||
}
|
||||
|
||||
// NewPKCE generates a high-entropy code verifier and its S256 challenge. It
|
||||
// matches internal/mcp's newPKCE byte-for-byte (RawURLEncoding of 32 random
|
||||
// bytes; challenge = base64url(SHA-256(verifier))) so the two engines are
|
||||
// interchangeable.
|
||||
func NewPKCE() (PKCE, error) {
|
||||
raw := make([]byte, pkceVerifierBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return PKCE{}, fmt.Errorf("oauth: generate PKCE verifier: %w", err)
|
||||
}
|
||||
verifier := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
return PKCE{Verifier: verifier, Challenge: challenge, Method: MethodS256}, nil
|
||||
}
|
||||
|
||||
// NewState generates a high-entropy CSRF state value.
|
||||
func NewState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("oauth: generate state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package oauth
|
||||
|
||||
import "strings"
|
||||
|
||||
// providerPreset is a baked-in default OAuth configuration for a well-known
|
||||
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
|
||||
// env vars (env wins). Only providers whose OAuth flow is verified to yield a
|
||||
// credential usable for model calls are listed here.
|
||||
type providerPreset struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthorizationEndpoint string
|
||||
TokenEndpoint string
|
||||
DeviceAuthorizationEndpoint string
|
||||
IssuerURL string
|
||||
Scopes []string
|
||||
Flow Flow
|
||||
}
|
||||
|
||||
// builtinOAuthPresets maps a provider name to its default OAuth config.
|
||||
//
|
||||
// These presets are OFF by default and only consulted when the operator opts in
|
||||
// with ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed). A preset carries a
|
||||
// third-party OAuth client identity, and the engine keeps such identities out of
|
||||
// the default credential path (see the package doc) — opting in is an explicit
|
||||
// acknowledgement that the binary's preset client_id will be used when no
|
||||
// ZERO_OAUTH_<NAME>_* override is set.
|
||||
//
|
||||
// xAI (Grok): the client_id is a PUBLIC client (no secret) used by several Grok
|
||||
// CLIs; its access token is accepted directly as a bearer on api.x.ai/v1 (an
|
||||
// OpenAI-compatible endpoint), so no header/identity spoofing is involved.
|
||||
// CAVEATS: it is NOT formally documented by xAI as a public developer API and may
|
||||
// change without notice (override via ZERO_OAUTH_XAI_*), and using it requires a
|
||||
// SuperGrok / X Premium+ subscription. Pay-as-you-go users should use a console
|
||||
// API key instead.
|
||||
var builtinOAuthPresets = map[string]providerPreset{
|
||||
"xai": {
|
||||
ClientID: "b1a00492-073a-47ea-816f-4c329264a828",
|
||||
AuthorizationEndpoint: "https://auth.x.ai/oauth2/authorize",
|
||||
TokenEndpoint: "https://auth.x.ai/oauth2/token",
|
||||
DeviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
|
||||
IssuerURL: "https://auth.x.ai",
|
||||
Scopes: []string{"openid", "profile", "email", "offline_access", "grok-cli:access", "api:access"},
|
||||
Flow: FlowLoopback,
|
||||
},
|
||||
}
|
||||
|
||||
// lookupOAuthPreset returns the baked-in preset for a provider name (if any).
|
||||
func lookupOAuthPreset(name string) (providerPreset, bool) {
|
||||
preset, ok := builtinOAuthPresets[strings.ToLower(strings.TrimSpace(name))]
|
||||
return preset, ok
|
||||
}
|
||||
|
||||
// presetsAllowed reports whether baked-in OAuth presets may supply defaults. They
|
||||
// are OFF unless the operator opts in with ZERO_OAUTH_ALLOW_PRESETS set to a
|
||||
// truthy value, keeping any third-party OAuth client identity out of the default
|
||||
// credential path (a preset client_id is only ever used after explicit opt-in).
|
||||
func presetsAllowed(env map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(envValue(env, "ZERO_OAUTH_ALLOW_PRESETS"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopesOrPreset returns the env scopes (space-separated) when set, else the
|
||||
// preset's scopes.
|
||||
func scopesOrPreset(envScopes string, preset []string) []string {
|
||||
if fields := strings.Fields(envScopes); len(fields) > 0 {
|
||||
return fields
|
||||
}
|
||||
// Copy so a caller appending to cfg.Scopes can't mutate the shared preset slice.
|
||||
return append([]string(nil), preset...)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScopesOrPresetReturnsACopy(t *testing.T) {
|
||||
preset := []string{"openid", "profile"}
|
||||
got := scopesOrPreset("", preset)
|
||||
if len(got) != 2 || got[0] != "openid" {
|
||||
t.Fatalf("got %v, want the preset scopes", got)
|
||||
}
|
||||
// Mutating the result must not bleed into the shared preset slice.
|
||||
got[0] = "MUTATED"
|
||||
if preset[0] != "openid" {
|
||||
t.Fatalf("scopesOrPreset aliased the shared preset slice: %v", preset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigUsesXAIPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// Presets are opt-in; with ZERO_OAUTH_ALLOW_PRESETS set and no other env vars
|
||||
// the preset supplies everything.
|
||||
cfg, flow, err := r.ResolveConfig("xai", map[string]string{"ZERO_OAUTH_ALLOW_PRESETS": "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig(xai): %v", err)
|
||||
}
|
||||
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
|
||||
t.Fatalf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.AuthorizationEndpoint != "https://auth.x.ai/oauth2/authorize" {
|
||||
t.Fatalf("authorize = %q", cfg.AuthorizationEndpoint)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
|
||||
t.Fatalf("token = %q", cfg.TokenEndpoint)
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint != "https://auth.x.ai/oauth2/device/code" {
|
||||
t.Fatalf("device = %q", cfg.DeviceAuthorizationEndpoint)
|
||||
}
|
||||
if flow != FlowLoopback {
|
||||
t.Fatalf("flow = %q, want loopback", flow)
|
||||
}
|
||||
if len(cfg.Scopes) == 0 {
|
||||
t.Fatal("preset scopes should be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_ALLOW_PRESETS": "1",
|
||||
"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id",
|
||||
"ZERO_OAUTH_XAI_SCOPES": "alpha beta",
|
||||
"ZERO_OAUTH_XAI_FLOW": "device",
|
||||
}
|
||||
cfg, flow, err := r.ResolveConfig("xai", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig(xai, env): %v", err)
|
||||
}
|
||||
if cfg.ClientID != "custom-id" {
|
||||
t.Fatalf("env should override client_id, got %q", cfg.ClientID)
|
||||
}
|
||||
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "alpha" {
|
||||
t.Fatalf("env should override scopes, got %v", cfg.Scopes)
|
||||
}
|
||||
if flow != FlowDevice {
|
||||
t.Fatalf("env should override flow, got %q", flow)
|
||||
}
|
||||
// A field not overridden still comes from the preset.
|
||||
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
|
||||
t.Fatalf("non-overridden token endpoint = %q", cfg.TokenEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigNoPresetStillRequiresEnv(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, _, err := r.ResolveConfig("acme-no-preset", map[string]string{}); err == nil {
|
||||
t.Fatal("a provider with neither preset nor env config should error")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the opt-in flag the xAI preset must stay inert: no third-party client
|
||||
// identity is baked into the default credential path, and the error points the
|
||||
// user at the opt-in.
|
||||
func TestResolveConfigPresetInertWithoutOptIn(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
_, _, err := r.ResolveConfig("xai", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("xai must not resolve from the preset unless ZERO_OAUTH_ALLOW_PRESETS is set")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ZERO_OAUTH_ALLOW_PRESETS") {
|
||||
t.Fatalf("error should point at the opt-in, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Flow selects how a provider delivers the authorization result.
|
||||
type Flow string
|
||||
|
||||
const (
|
||||
// FlowLoopback uses a 127.0.0.1 callback server (browser required).
|
||||
FlowLoopback Flow = "loopback"
|
||||
// FlowDevice uses the RFC 8628 device-code flow (headless/SSH).
|
||||
FlowDevice Flow = "device"
|
||||
)
|
||||
|
||||
// providerNamePattern bounds a provider name to a safe identifier that is also a
|
||||
// valid store-key segment.
|
||||
var providerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ValidateProviderName reports whether name is a safe provider identifier.
|
||||
func ValidateProviderName(name string) error {
|
||||
if !providerNamePattern.MatchString(name) {
|
||||
return fmt.Errorf("oauth: invalid provider name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Registry resolves a provider's Config from env/config. By default every provider
|
||||
// is defined entirely by the operator via ZERO_OAUTH_<NAME>_* variables, so no
|
||||
// third-party OAuth client identity is used. A small set of built-in presets
|
||||
// exists for convenience but stays inert unless the operator opts in with
|
||||
// ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed); env always overrides a preset.
|
||||
type Registry struct{}
|
||||
|
||||
// NewRegistry returns the (stateless) env-driven registry.
|
||||
func NewRegistry() *Registry { return &Registry{} }
|
||||
|
||||
// envKey builds the ZERO_OAUTH_<NAME>_<suffix> variable name for a provider.
|
||||
func envKey(name, suffix string) string {
|
||||
up := strings.ToUpper(name)
|
||||
up = strings.NewReplacer("-", "_", ".", "_").Replace(up)
|
||||
return "ZERO_OAUTH_" + up + "_" + suffix
|
||||
}
|
||||
|
||||
// ResolveConfig builds the oauth.Config and default Flow for a provider from its
|
||||
// env/config:
|
||||
//
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_ID (required)
|
||||
// ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
|
||||
// ZERO_OAUTH_<NAME>_AUTHORIZE_URL (loopback flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_TOKEN_URL (or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_DEVICE_URL (device flow; or discovered via issuer)
|
||||
// ZERO_OAUTH_<NAME>_ISSUER_URL (RFC 8414 / OIDC discovery base)
|
||||
// ZERO_OAUTH_<NAME>_SCOPES (space-separated)
|
||||
// ZERO_OAUTH_<NAME>_FLOW ("loopback" [default] | "device")
|
||||
//
|
||||
// Pinned credential-bearing endpoints must be https (loopback exempt).
|
||||
func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Flow, error) {
|
||||
if err := ValidateProviderName(name); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
// A baked-in preset (if any) supplies defaults, but ONLY when the operator opts
|
||||
// in with ZERO_OAUTH_ALLOW_PRESETS — otherwise no third-party client identity is
|
||||
// used and every field must come from a ZERO_OAUTH_<NAME>_* env var (env wins).
|
||||
var preset providerPreset
|
||||
if presetsAllowed(env) {
|
||||
preset, _ = lookupOAuthPreset(name)
|
||||
}
|
||||
cfg := Config{
|
||||
ClientID: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))), preset.ClientID),
|
||||
ClientSecret: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))), preset.ClientSecret),
|
||||
AuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))), preset.AuthorizationEndpoint),
|
||||
TokenEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))), preset.TokenEndpoint),
|
||||
DeviceAuthorizationEndpoint: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))), preset.DeviceAuthorizationEndpoint),
|
||||
IssuerURL: firstNonEmpty(strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))), preset.IssuerURL),
|
||||
Scopes: scopesOrPreset(envValue(env, envKey(name, "SCOPES")), preset.Scopes),
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
hint := ""
|
||||
if _, ok := lookupOAuthPreset(name); ok {
|
||||
hint = " (or set ZERO_OAUTH_ALLOW_PRESETS=1 to use the built-in preset)"
|
||||
}
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)%s", name, envKey(name, "CLIENT_ID"), hint)
|
||||
}
|
||||
var flow Flow
|
||||
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
|
||||
case "":
|
||||
flow = preset.Flow
|
||||
if flow == "" {
|
||||
flow = FlowLoopback
|
||||
}
|
||||
case string(FlowLoopback):
|
||||
flow = FlowLoopback
|
||||
case string(FlowDevice):
|
||||
flow = FlowDevice
|
||||
default:
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q has invalid %s (want loopback or device)", name, envKey(name, "FLOW"))
|
||||
}
|
||||
// A token endpoint (for exchange/refresh) must be reachable directly or via
|
||||
// discovery. The per-flow authorize/device endpoints are checked at login
|
||||
// time (after discovery), so a refresh-only config needs only a token URL.
|
||||
if cfg.IssuerURL == "" && cfg.TokenEndpoint == "" {
|
||||
return Config{}, "", fmt.Errorf("oauth: provider %q needs %s or %s", name, envKey(name, "TOKEN_URL"), envKey(name, "ISSUER_URL"))
|
||||
}
|
||||
for _, ep := range []string{cfg.TokenEndpoint, cfg.AuthorizationEndpoint, cfg.DeviceAuthorizationEndpoint, cfg.IssuerURL} {
|
||||
if ep == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateTokenEndpoint(ep); err != nil {
|
||||
return Config{}, "", err
|
||||
}
|
||||
}
|
||||
return cfg, flow, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveConfigFromEnv(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "my-client",
|
||||
"ZERO_OAUTH_DEMO_CLIENT_SECRET": "shh",
|
||||
"ZERO_OAUTH_DEMO_SCOPES": "read write",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
}
|
||||
cfg, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "my-client" || cfg.ClientSecret != "shh" {
|
||||
t.Fatalf("client creds not applied: %+v", cfg)
|
||||
}
|
||||
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "read" {
|
||||
t.Fatalf("scopes = %v", cfg.Scopes)
|
||||
}
|
||||
if cfg.TokenEndpoint != "https://auth.example.com/token" {
|
||||
t.Fatalf("token endpoint = %q", cfg.TokenEndpoint)
|
||||
}
|
||||
if flow != FlowLoopback {
|
||||
t.Fatalf("flow = %q, want loopback default", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigDeviceFlow(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
|
||||
"ZERO_OAUTH_DEMO_DEVICE_URL": "https://auth.example.com/device",
|
||||
"ZERO_OAUTH_DEMO_FLOW": "device",
|
||||
}
|
||||
_, flow, err := r.ResolveConfig("demo", env)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConfig: %v", err)
|
||||
}
|
||||
if flow != FlowDevice {
|
||||
t.Fatalf("flow = %q, want device", flow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresClientID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, _, err := r.ResolveConfig("demo", map[string]string{}); err == nil {
|
||||
t.Fatal("missing client id must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRequiresEndpointsOrIssuer(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
// client id but no token endpoint and no issuer => error.
|
||||
_, _, err := r.ResolveConfig("demo", map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"})
|
||||
if err == nil {
|
||||
t.Fatal("missing endpoints/issuer must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigRejectsInsecureEndpoint(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
env := map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": "http://insecure.example/token", // non-https, non-loopback
|
||||
}
|
||||
_, _, err := r.ResolveConfig("demo", env)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigInvalidName(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
for _, bad := range []string{"", "has space", "../escape", "a/b"} {
|
||||
if _, _, err := r.ResolveConfig(bad, nil); err == nil {
|
||||
t.Errorf("ResolveConfig(%q) should reject invalid name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvKey(t *testing.T) {
|
||||
if got := envKey("my-svc", "CLIENT_ID"); got != "ZERO_OAUTH_MY_SVC_CLIENT_ID" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
if got := envKey("two.part", "SCOPES"); got != "ZERO_OAUTH_TWO_PART_SCOPES" {
|
||||
t.Fatalf("envKey = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxSchedulerLoadErrors bounds consecutive token-load failures before the
|
||||
// scheduler gives up, so a transient error retries but a permanent one (e.g. a
|
||||
// deleted token) does not loop forever.
|
||||
const maxSchedulerLoadErrors = 5
|
||||
|
||||
// RefreshScheduler proactively refreshes a stored provider token shortly before
|
||||
// it expires. It is an OPTIMIZATION over the on-demand GetFresh path, never the
|
||||
// source of truth: a failed scheduled refresh is non-fatal and simply retried at
|
||||
// the next expiry. It is a no-op for a token with no refresh token or no expiry.
|
||||
type RefreshScheduler struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewRefreshScheduler returns an idle scheduler.
|
||||
func NewRefreshScheduler() *RefreshScheduler {
|
||||
return &RefreshScheduler{}
|
||||
}
|
||||
|
||||
// Start begins proactively refreshing key via the manager until the context is
|
||||
// canceled or Stop is called. It returns immediately; refresh happens in a
|
||||
// goroutine. Calling Start twice is a no-op after the first.
|
||||
func (s *RefreshScheduler) Start(ctx context.Context, m *Manager, key string) {
|
||||
s.mu.Lock()
|
||||
if s.started {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.done = make(chan struct{})
|
||||
s.started = true
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.loop(runCtx, m, key, done)
|
||||
}
|
||||
|
||||
func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, done chan struct{}) {
|
||||
defer close(done)
|
||||
loadErrors := 0
|
||||
for {
|
||||
token, err := m.loadToken(key)
|
||||
if err != nil {
|
||||
// A load error may be transient (a concurrent store write, a brief I/O
|
||||
// hiccup): back off and retry rather than permanently stopping proactive
|
||||
// refresh. Give up only after repeated consecutive failures.
|
||||
loadErrors++
|
||||
if loadErrors >= maxSchedulerLoadErrors {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
continue
|
||||
}
|
||||
loadErrors = 0
|
||||
// No refresh token or no expiry => nothing to schedule on a timer.
|
||||
if token.RefreshToken == "" || token.ExpiresAt.IsZero() {
|
||||
return
|
||||
}
|
||||
delay := s.delayUntilRefresh(token.ExpiresAt, m.buffer, m.now())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
// Best-effort refresh; failure is non-fatal — reload and reschedule (the
|
||||
// on-demand GetFresh remains the safety net for callers).
|
||||
if _, err := m.GetFresh(ctx, key); err != nil {
|
||||
// Wait a bounded backoff before retrying so a persistently failing
|
||||
// refresh does not hot-loop.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(m.buffer):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// delayUntilRefresh computes how long to wait before refreshing: aim for buffer
|
||||
// before expiry, clamped to >= 0, plus a small deterministic jitter so many
|
||||
// sessions do not refresh in lockstep.
|
||||
func (s *RefreshScheduler) delayUntilRefresh(expiresAt time.Time, buffer time.Duration, now time.Time) time.Duration {
|
||||
target := expiresAt.Add(-buffer)
|
||||
delay := target.Sub(now)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
// Jitter up to ~10% of the buffer, derived from the expiry nanos (no RNG dep).
|
||||
if buffer > 0 {
|
||||
jitter := time.Duration(expiresAt.UnixNano()%int64(buffer/10+1)) % (buffer/10 + 1)
|
||||
delay += jitter
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// Stop cancels the scheduler and waits for its goroutine to exit. Safe to call
|
||||
// more than once and on a never-started scheduler.
|
||||
func (s *RefreshScheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
done := s.done
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRefreshSchedulerNoRefreshTokenIsNoop(t *testing.T) {
|
||||
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
|
||||
// Token without a refresh token => scheduler must exit promptly (no-op).
|
||||
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
// Stop waits for the goroutine; if it didn't exit on its own this still returns
|
||||
// because Stop cancels — but the loop should already be done.
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestRefreshSchedulerRefreshesBeforeExpiry(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = io.WriteString(w, `{"access_token":"refreshed","expires_in":3600}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
m, err := NewManager(ManagerOptions{
|
||||
Store: store,
|
||||
Env: map[string]string{
|
||||
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
|
||||
"ZERO_OAUTH_DEMO_TOKEN_URL": server.URL,
|
||||
},
|
||||
RefreshBuffer: 10 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
// Expiry just past the buffer so the scheduled delay is ~0 and a refresh fires
|
||||
// almost immediately.
|
||||
if err := store.Save(ProviderKey("demo"), Token{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(15 * time.Millisecond)}); err != nil {
|
||||
t.Fatalf("seed Save: %v", err)
|
||||
}
|
||||
|
||||
s := NewRefreshScheduler()
|
||||
s.Start(context.Background(), m, ProviderKey("demo"))
|
||||
defer s.Stop()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
stored, _, err := store.Load(ProviderKey("demo"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if stored.AccessToken == "refreshed" {
|
||||
return // success
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("scheduler did not refresh before expiry (token endpoint hits=%d)", hits.Load())
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/keyring"
|
||||
)
|
||||
|
||||
const (
|
||||
storeSchemaVersion = 1
|
||||
// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
|
||||
// under KeyPrefixMCP in the same format (so a future MCP migration is a key
|
||||
// rename, not a format change).
|
||||
KeyPrefixProvider = "provider:"
|
||||
KeyPrefixMCP = "mcp:"
|
||||
)
|
||||
|
||||
// keyPattern bounds a token key to a safe, single-segment namespaced identifier
|
||||
// so a key can never traverse or collide with store internals.
|
||||
var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ValidateKey reports whether key is a well-formed namespaced token key.
|
||||
func ValidateKey(key string) error {
|
||||
if !keyPattern.MatchString(key) {
|
||||
return fmt.Errorf("oauth: invalid token key %q (want \"provider:<name>\" or \"mcp:<name>\")", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + name }
|
||||
|
||||
// Status is a redaction-safe summary of a stored token (no secret material).
|
||||
type Status struct {
|
||||
Key string `json:"key"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
HasRefreshToken bool `json:"hasRefreshToken"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
}
|
||||
|
||||
// StoreOptions configures where provider OAuth tokens are persisted.
|
||||
type StoreOptions struct {
|
||||
FilePath string
|
||||
Env map[string]string
|
||||
Now func() time.Time
|
||||
// Storage selects the backend: "" / "file" => a 0600 JSON file (default);
|
||||
// "encrypted-file" => an AES-256-GCM encrypted file; "keyring" => the OS
|
||||
// keyring. When empty it falls back to ZERO_OAUTH_STORAGE.
|
||||
Storage string
|
||||
// Encrypted is a legacy alias for Storage=="encrypted-file" (AES-256-GCM at
|
||||
// rest). Ignored when Storage is set.
|
||||
Encrypted bool
|
||||
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
|
||||
// Injected by tests to avoid touching a real keychain.
|
||||
Keyring KeyringClient
|
||||
}
|
||||
|
||||
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
|
||||
// satisfies it; tests inject a fake.
|
||||
type KeyringClient interface {
|
||||
Get(service, account string) (string, bool, error)
|
||||
Set(service, account, secret string) error
|
||||
Delete(service, account string) (bool, error)
|
||||
}
|
||||
|
||||
// Keyring storage stores the whole token blob under one fixed entry.
|
||||
const (
|
||||
keyringService = "zero"
|
||||
keyringAccount = "oauth-tokens"
|
||||
)
|
||||
|
||||
// Store persists OAuth tokens (provider + MCP namespaces) as one JSON blob,
|
||||
// written atomically through a pluggable backend (a 0600 file guarded by a
|
||||
// cross-process lock, or the OS keyring). When crypter is non-nil the file blob
|
||||
// is AES-256-GCM ciphertext at rest.
|
||||
type Store struct {
|
||||
blob blobStore
|
||||
crypter *aesGCMCrypter // nil => plaintext blob
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type storeFile struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Tokens map[string]Token `json:"tokens"`
|
||||
}
|
||||
|
||||
// ResolveStorePath determines the on-disk location for provider OAuth tokens,
|
||||
// honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.
|
||||
func ResolveStorePath(env map[string]string) (string, error) {
|
||||
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_TOKENS_PATH")); override != "" {
|
||||
if filepath.IsAbs(override) {
|
||||
return filepath.Clean(override), nil
|
||||
}
|
||||
return filepath.Abs(override)
|
||||
}
|
||||
configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME"))
|
||||
if configHome == "" {
|
||||
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
|
||||
if home == "" {
|
||||
var err error
|
||||
home, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: resolve user home: %w", err)
|
||||
}
|
||||
}
|
||||
configHome = filepath.Join(home, ".config")
|
||||
} else if !filepath.IsAbs(configHome) {
|
||||
resolved, err := filepath.Abs(configHome)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configHome = resolved
|
||||
}
|
||||
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
|
||||
}
|
||||
|
||||
// NewStore builds a token store with the configured backend (file by default,
|
||||
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
|
||||
func NewStore(options StoreOptions) (*Store, error) {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
storage := strings.TrimSpace(options.Storage)
|
||||
if storage == "" {
|
||||
storage = strings.TrimSpace(envValue(options.Env, "ZERO_OAUTH_STORAGE"))
|
||||
}
|
||||
if storage == "" && options.Encrypted {
|
||||
storage = "encrypted-file" // legacy alias
|
||||
}
|
||||
switch storage {
|
||||
case "", "file":
|
||||
path, err := resolveStoreFilePath(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{blob: fileBlob{path: path}, now: now}, nil
|
||||
case "encrypted-file":
|
||||
path, err := resolveStoreFilePath(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The file blob holds AES-256-GCM ciphertext; the per-user secret lives in
|
||||
// a sibling ".secret" file (see encrypt.go).
|
||||
return &Store{blob: fileBlob{path: path}, crypter: newAESGCMCrypter(path + ".secret"), now: now}, nil
|
||||
case "keyring":
|
||||
kr := options.Keyring
|
||||
if kr == nil {
|
||||
osKeyring := keyring.New()
|
||||
if !osKeyring.Available() {
|
||||
return nil, fmt.Errorf("oauth: keyring storage requested but not available on %s; use file storage", runtime.GOOS)
|
||||
}
|
||||
kr = osKeyring
|
||||
}
|
||||
// Serialize the keyring's read-modify-write across processes with a lock
|
||||
// file beside where the file backend would live. Best-effort: if no config
|
||||
// location resolves, fall back to in-process serialization only.
|
||||
lockPath := ""
|
||||
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
|
||||
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
|
||||
}
|
||||
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("oauth: unknown storage %q (want \"file\", \"encrypted-file\", or \"keyring\")", storage)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStoreFilePath resolves the absolute file path for the file backend.
|
||||
func resolveStoreFilePath(options StoreOptions) (string, error) {
|
||||
filePath := options.FilePath
|
||||
var err error
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath, err = ResolveStorePath(options.Env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if !filepath.IsAbs(filePath) {
|
||||
filePath, err = filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return filepath.Clean(filePath), nil
|
||||
}
|
||||
|
||||
// FilePath returns the resolved token store location (a path for the file
|
||||
// backend, or a "keyring:..." identifier for the keyring backend).
|
||||
func (s *Store) FilePath() string { return s.blob.location() }
|
||||
|
||||
// Save persists a token under key, replacing any existing entry.
|
||||
func (s *Store) Save(key string, token Token) error {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.blob.withLock(s.now, func() error {
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Tokens[key] = token
|
||||
return s.writeState(state)
|
||||
})
|
||||
}
|
||||
|
||||
// Load returns the token for key; the bool is false when none is stored.
|
||||
func (s *Store) Load(key string) (Token, bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return Token{}, false, err
|
||||
}
|
||||
token, ok := state.Tokens[key]
|
||||
return token, ok, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for key, reporting whether one was present.
|
||||
func (s *Store) Delete(key string) (bool, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var removed bool
|
||||
err := s.blob.withLock(s.now, func() error {
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := state.Tokens[key]; !ok {
|
||||
return nil
|
||||
}
|
||||
delete(state.Tokens, key)
|
||||
removed = true
|
||||
return s.writeState(state)
|
||||
})
|
||||
return removed, err
|
||||
}
|
||||
|
||||
// Status returns redaction-safe summaries of every stored token, sorted by key.
|
||||
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
|
||||
func (s *Store) Status(prefix string) ([]Status, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
state, err := s.readState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(state.Tokens))
|
||||
for k := range state.Tokens {
|
||||
if prefix == "" || strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
now := s.now()
|
||||
out := make([]Status, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
token := state.Tokens[k]
|
||||
out = append(out, Status{
|
||||
Key: k,
|
||||
HasToken: strings.TrimSpace(token.AccessToken) != "",
|
||||
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
|
||||
TokenType: token.TokenType,
|
||||
Account: token.Account,
|
||||
Scopes: token.Scopes,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
Expired: token.Expired(now),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) readState() (storeFile, error) {
|
||||
data, ok, err := s.blob.read()
|
||||
if err != nil {
|
||||
return storeFile{}, err
|
||||
}
|
||||
if !ok {
|
||||
return emptyStoreFile(), nil
|
||||
}
|
||||
if s.crypter != nil {
|
||||
// Encrypted backend: the blob is AES-256-GCM ciphertext, not JSON.
|
||||
data, err = s.crypter.open(data)
|
||||
if err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: decrypt token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
}
|
||||
var state storeFile
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
if state.SchemaVersion != storeSchemaVersion {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: unsupported schemaVersion", s.blob.location())
|
||||
}
|
||||
if state.Tokens == nil {
|
||||
state.Tokens = map[string]Token{}
|
||||
}
|
||||
for key := range state.Tokens {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return storeFile{}, fmt.Errorf("oauth: invalid token store at %s: %w", s.blob.location(), err)
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeState(state storeFile) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Plaintext keeps the trailing newline for a tidy file; the encrypted backend
|
||||
// writes opaque ciphertext instead.
|
||||
payload := append(data, '\n')
|
||||
if s.crypter != nil {
|
||||
payload, err = s.crypter.seal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.blob.write(payload)
|
||||
}
|
||||
|
||||
func emptyStoreFile() storeFile {
|
||||
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
|
||||
}
|
||||
|
||||
// blobStore abstracts the persistence of the whole token blob behind the Store,
|
||||
// so the same store logic backs either a 0600 file or the OS keyring.
|
||||
type blobStore interface {
|
||||
// read returns the stored blob; ok is false when nothing is stored yet.
|
||||
read() (data []byte, ok bool, err error)
|
||||
// write replaces the stored blob.
|
||||
write(data []byte) error
|
||||
// withLock runs fn under whatever cross-process exclusion the backend offers
|
||||
// (a lock file for the file backend; none for the keyring, which is the
|
||||
// authoritative store and is serialized within the process by Store.mu).
|
||||
withLock(now func() time.Time, fn func() error) error
|
||||
// location is a human-readable identifier for diagnostics/errors.
|
||||
location() string
|
||||
}
|
||||
|
||||
// fileBlob persists the blob as a 0600 JSON file, written atomically and guarded
|
||||
// by a cross-process lock file. Behavior matches the original file store.
|
||||
type fileBlob struct{ path string }
|
||||
|
||||
func (b fileBlob) read() ([]byte, bool, error) {
|
||||
data, err := os.ReadFile(b.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (b fileBlob) write(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := fmt.Sprintf("%s.tmp-%d-%d", b.path, os.Getpid(), time.Now().UnixNano())
|
||||
if err := os.WriteFile(tempPath, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, b.path); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b fileBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
unlock, err := acquireFileLock(b.path+".lockfile", now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (b fileBlob) location() string { return b.path }
|
||||
|
||||
// keyringBlob persists the blob in the OS keyring as a single base64 entry
|
||||
// (base64 keeps the multi-line JSON a single, control-character-free value).
|
||||
type keyringBlob struct {
|
||||
kr KeyringClient
|
||||
service string
|
||||
account string
|
||||
// lockPath, when set, is a cross-process lock file serializing the keyring's
|
||||
// read-modify-write so concurrent processes don't clobber each other's tokens.
|
||||
lockPath string
|
||||
}
|
||||
|
||||
func (b keyringBlob) read() ([]byte, bool, error) {
|
||||
enc, ok, err := b.kr.Get(b.service, b.account)
|
||||
if err != nil || !ok {
|
||||
return nil, ok, err
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("oauth: decode keyring token blob: %w", err)
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (b keyringBlob) write(data []byte) error {
|
||||
return b.kr.Set(b.service, b.account, base64.StdEncoding.EncodeToString(data))
|
||||
}
|
||||
|
||||
// withLock serializes the keyring's read-modify-write. Store.mu covers the
|
||||
// in-process case; lockPath (when set) adds cross-process exclusion so two
|
||||
// processes can't both read the blob, modify, and write — dropping a token.
|
||||
func (b keyringBlob) withLock(now func() time.Time, fn func() error) error {
|
||||
if b.lockPath == "" {
|
||||
return fn()
|
||||
}
|
||||
unlock, err := acquireFileLock(b.lockPath, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func (b keyringBlob) location() string { return "keyring:" + b.service + "/" + b.account }
|
||||
|
||||
// FormatStatuses renders a human-readable status table without leaking token
|
||||
// material.
|
||||
func FormatStatuses(statuses []Status) string {
|
||||
if len(statuses) == 0 {
|
||||
return "No OAuth provider logins are stored."
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, st := range statuses {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
name := strings.TrimPrefix(st.Key, KeyPrefixProvider)
|
||||
b.WriteString(name)
|
||||
b.WriteString(": ")
|
||||
if !st.HasToken {
|
||||
b.WriteString("no token")
|
||||
continue
|
||||
}
|
||||
b.WriteString("logged in")
|
||||
if st.Account != "" {
|
||||
b.WriteString(" as " + st.Account)
|
||||
}
|
||||
if st.HasRefreshToken {
|
||||
b.WriteString(" (refreshable)")
|
||||
}
|
||||
if !st.ExpiresAt.IsZero() {
|
||||
if st.Expired {
|
||||
b.WriteString(", expired at ")
|
||||
} else {
|
||||
b.WriteString(", expires ")
|
||||
}
|
||||
b.WriteString(st.ExpiresAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// envValue reads a variable. A non-nil env map is authoritative (hermetic): a
|
||||
// missing key returns "" rather than falling back to the process environment, so
|
||||
// a caller/test that passes a controlled map can never pick up ambient
|
||||
// ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME values. Only a nil map uses os.Getenv.
|
||||
func envValue(env map[string]string, key string) string {
|
||||
if env != nil {
|
||||
return env[key]
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeKR is an in-memory KeyringClient for exercising the keyring backend
|
||||
// without touching a real OS keychain.
|
||||
type fakeKR struct{ data map[string]string }
|
||||
|
||||
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}} }
|
||||
|
||||
func (f *fakeKR) Get(service, account string) (string, bool, error) {
|
||||
v, ok := f.data[service+"/"+account]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (f *fakeKR) Set(service, account, secret string) error {
|
||||
f.data[service+"/"+account] = secret
|
||||
return nil
|
||||
}
|
||||
func (f *fakeKR) Delete(service, account string) (bool, error) {
|
||||
key := service + "/" + account
|
||||
_, ok := f.data[key]
|
||||
delete(f.data, key)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
|
||||
// Keep the cross-process keyring lock file inside a temp config dir.
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
kr := newFakeKR()
|
||||
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(keyring): %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(s.FilePath(), "keyring:") {
|
||||
t.Fatalf("FilePath = %q, want keyring identifier", s.FilePath())
|
||||
}
|
||||
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
got, ok, err := s.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Load: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got.AccessToken != "a" || got.RefreshToken != "r" {
|
||||
t.Fatalf("Load = %#v", got)
|
||||
}
|
||||
|
||||
// The blob is stored base64-encoded, so the raw JSON field names never appear.
|
||||
raw := kr.data[keyringService+"/"+keyringAccount]
|
||||
if raw == "" {
|
||||
t.Fatal("nothing stored in keyring")
|
||||
}
|
||||
if strings.Contains(raw, "access_token") {
|
||||
t.Fatalf("keyring blob is not encoded: %s", raw)
|
||||
}
|
||||
|
||||
removed, err := s.Delete(ProviderKey("demo"))
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Delete: removed=%v err=%v", removed, err)
|
||||
}
|
||||
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
|
||||
t.Fatal("token still present after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStoreStorageSelection(t *testing.T) {
|
||||
// Unknown storage is rejected (fail closed).
|
||||
if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil {
|
||||
t.Fatal("unknown storage should error")
|
||||
}
|
||||
// ZERO_OAUTH_STORAGE selects the keyring (with an injected client).
|
||||
s, err := NewStore(StoreOptions{
|
||||
Env: map[string]string{"ZERO_OAUTH_STORAGE": "keyring"},
|
||||
Keyring: newFakeKR(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(env keyring): %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(s.FilePath(), "keyring:") {
|
||||
t.Fatalf("env did not select keyring backend: %q", s.FilePath())
|
||||
}
|
||||
// Default is the file backend.
|
||||
fileStore, err := NewStore(StoreOptions{FilePath: t.TempDir() + "/oauth-tokens.json"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(file): %v", err)
|
||||
}
|
||||
if strings.HasPrefix(fileStore.FilePath(), "keyring:") {
|
||||
t.Fatalf("default backend should be file, got %q", fileStore.FilePath())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreKeyringStatus(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
kr := newFakeKR()
|
||||
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statuses, err := s.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") || !statuses[0].HasToken {
|
||||
t.Fatalf("status = %#v", statuses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
|
||||
s, err := NewStore(StoreOptions{FilePath: path})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
func TestStoreSaveLoadDelete(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
tok := Token{AccessToken: "at", RefreshToken: "rt", Account: "me@x"}
|
||||
if err := s.Save(ProviderKey("demo"), tok); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Save(KeyPrefixMCP+"server1", Token{AccessToken: "mcp-at"}); err != nil {
|
||||
t.Fatalf("Save mcp: %v", err)
|
||||
}
|
||||
got, ok, err := s.Load(ProviderKey("demo"))
|
||||
if err != nil || !ok || got.AccessToken != "at" || got.Account != "me@x" {
|
||||
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
removed, err := s.Delete(ProviderKey("demo"))
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("Delete = %v %v", removed, err)
|
||||
}
|
||||
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
|
||||
t.Fatal("token should be gone after delete")
|
||||
}
|
||||
// The mcp-namespaced token is untouched.
|
||||
if _, ok, _ := s.Load(KeyPrefixMCP + "server1"); !ok {
|
||||
t.Fatal("mcp token should survive provider delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsInvalidKeys(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
for _, bad := range []string{"demo", "provider:", "provider:../escape", "mcp:bad/key", "other:x", ""} {
|
||||
if err := s.Save(bad, Token{AccessToken: "x"}); err == nil {
|
||||
t.Errorf("Save(%q) should be rejected", bad)
|
||||
}
|
||||
}
|
||||
for _, ok := range []string{"provider:demo", "mcp:server-1", "provider:two-svc"} {
|
||||
if err := ValidateKey(ok); err != nil {
|
||||
t.Errorf("ValidateKey(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreStatusFiltersByPrefix(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
_ = s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
_ = s.Save(KeyPrefixMCP+"srv", Token{AccessToken: "m"})
|
||||
statuses, err := s.Status(KeyPrefixProvider)
|
||||
if err != nil {
|
||||
t.Fatalf("Status: %v", err)
|
||||
}
|
||||
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") {
|
||||
t.Fatalf("provider status = %+v", statuses)
|
||||
}
|
||||
if !statuses[0].HasRefreshToken {
|
||||
t.Fatal("status should report a refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFileMode0600(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix file modes")
|
||||
}
|
||||
s, path := newTestStore(t)
|
||||
if err := s.Save(ProviderKey("x"), Token{AccessToken: "a"}); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("token file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMalformedFailsClosed(t *testing.T) {
|
||||
s, path := newTestStore(t)
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("corrupt: %v", err)
|
||||
}
|
||||
if _, _, err := s.Load(ProviderKey("x")); err == nil {
|
||||
t.Fatal("malformed store must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvValueHermetic(t *testing.T) {
|
||||
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "ambient")
|
||||
// A non-nil map omitting the key must NOT leak the ambient process value.
|
||||
if got := envValue(map[string]string{}, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "" {
|
||||
t.Fatalf("non-nil env map must be hermetic, got %q", got)
|
||||
}
|
||||
// A nil map reads the process environment.
|
||||
if got := envValue(nil, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "ambient" {
|
||||
t.Fatalf("nil env map should read os env, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorePathHonorsOverride(t *testing.T) {
|
||||
// Use an OS-appropriate absolute path: a unix-style "/tmp/..." literal is not
|
||||
// absolute on Windows (no drive), so it would be resolved against the current
|
||||
// drive and a verbatim comparison would fail there.
|
||||
override := filepath.Join(t.TempDir(), "custom", "tok.json")
|
||||
path, err := ResolveStorePath(map[string]string{"ZERO_OAUTH_TOKENS_PATH": override})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveStorePath: %v", err)
|
||||
}
|
||||
if path != override {
|
||||
t.Fatalf("path = %q, want %q", path, override)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,17 @@ type Descriptor struct {
|
||||
Local bool
|
||||
SupportedAPIFormats []APIFormat
|
||||
Aliases []string
|
||||
|
||||
// OAuth reports that this provider offers an in-app OAuth login that yields a
|
||||
// credential usable for model calls (browser PKCE and/or device code). Only
|
||||
// set for providers where this actually works (not subscription-via-proxy).
|
||||
OAuth bool
|
||||
// OAuthMintsKey reports that the OAuth flow returns/mints a normal API key
|
||||
// (e.g. OpenRouter) rather than a bearer token used directly.
|
||||
OAuthMintsKey bool
|
||||
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
|
||||
// headless / SSH use) in addition to the browser flow.
|
||||
OAuthDeviceFlow bool
|
||||
}
|
||||
|
||||
func RuntimeSupported(descriptor Descriptor) bool {
|
||||
@@ -77,7 +88,7 @@ var descriptors = []Descriptor{
|
||||
openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"),
|
||||
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
|
||||
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
|
||||
openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}),
|
||||
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
|
||||
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
|
||||
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
|
||||
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
|
||||
@@ -89,13 +100,20 @@ var descriptors = []Descriptor{
|
||||
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
|
||||
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
|
||||
transportDescriptor("vertex", "Vertex AI", TransportVertex, "https://aiplatform.googleapis.com", "gemini-2.5-pro", []string{"GOOGLE_APPLICATION_CREDENTIALS"}, []APIFormat{APIFormatVertexGenerateContent}, true),
|
||||
openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}),
|
||||
oauthProvider(openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}), false, true),
|
||||
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
// the official client), so zero does not call it directly; instead point this
|
||||
// at a local proxy that holds the OAuth session and exposes an OpenAI-compatible
|
||||
// endpoint. Local (no API key — the proxy authenticates); override the base URL
|
||||
// for your proxy's port. See docs/oauth-subscriptions.md.
|
||||
localOpenAI("chatgpt-proxy", "ChatGPT (local OAuth proxy)", "http://localhost:10531/v1", "gpt-5", "chatgpt"),
|
||||
openAICompat("custom-openai-compatible", "Custom OpenAI-compatible", "https://example.invalid/v1", "custom-model", []string{"OPENAI_API_KEY"}, "custom openai compatible"),
|
||||
anthropicCompat("custom-anthropic-compatible", "Custom Anthropic-compatible", "https://example.invalid/anthropic", "custom-model", []string{"ANTHROPIC_API_KEY"}, "custom anthropic compatible"),
|
||||
}
|
||||
@@ -229,6 +247,28 @@ func google(id string, name string, baseURL string, model string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
|
||||
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
|
||||
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
|
||||
descriptor.OAuth = true
|
||||
descriptor.OAuthMintsKey = mintsKey
|
||||
descriptor.OAuthDeviceFlow = deviceFlow
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// OAuthProviders returns the catalog descriptors that support an in-app OAuth
|
||||
// login, in catalog order. It is the single source of truth for the wizard's
|
||||
// dedicated "Sign in with OAuth" provider list.
|
||||
func OAuthProviders() []Descriptor {
|
||||
out := []Descriptor{}
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.OAuth {
|
||||
out = append(out, cloneDescriptor(descriptor))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func localOpenAI(id string, name string, baseURL string, model string, aliases ...string) Descriptor {
|
||||
descriptor := openAICompat(id, name, baseURL, model, nil, aliases...)
|
||||
descriptor.RequiresAuth = false
|
||||
|
||||
@@ -33,6 +33,7 @@ var expectedCatalogIDs = []string{
|
||||
"zai",
|
||||
"gitlawb-opengateway",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
"custom-anthropic-compatible",
|
||||
}
|
||||
@@ -220,7 +221,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
@@ -277,6 +278,19 @@ func TestReturnedDescriptorsAreCopies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthProviderClassification(t *testing.T) {
|
||||
oauthIDs := descriptorIDs(OAuthProviders())
|
||||
if want := []string{"openrouter", "xai"}; !reflect.DeepEqual(oauthIDs, want) {
|
||||
t.Fatalf("OAuthProviders() = %#v, want %#v", oauthIDs, want)
|
||||
}
|
||||
if d, _ := Get("openrouter"); !d.OAuthMintsKey {
|
||||
t.Fatal("openrouter should mint a key")
|
||||
}
|
||||
if d, _ := Get("xai"); !d.OAuthDeviceFlow {
|
||||
t.Fatal("xai should advertise device-code flow")
|
||||
}
|
||||
}
|
||||
|
||||
func descriptorIDs(descriptors []Descriptor) []string {
|
||||
ids := make([]string, 0, len(descriptors))
|
||||
for _, descriptor := range descriptors {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package providercatalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOAuthProviders(t *testing.T) {
|
||||
providers := OAuthProviders()
|
||||
if len(providers) != 2 {
|
||||
t.Fatalf("OAuthProviders() = %d, want 2 (openrouter, xai)", len(providers))
|
||||
}
|
||||
byID := map[string]Descriptor{}
|
||||
for _, d := range providers {
|
||||
if !d.OAuth {
|
||||
t.Fatalf("OAuthProviders() returned a non-OAuth descriptor %q", d.ID)
|
||||
}
|
||||
byID[d.ID] = d
|
||||
}
|
||||
or, ok := byID["openrouter"]
|
||||
if !ok || !or.OAuthMintsKey || or.OAuthDeviceFlow {
|
||||
t.Fatalf("openrouter oauth flags wrong: %+v", or)
|
||||
}
|
||||
xai, ok := byID["xai"]
|
||||
if !ok || xai.OAuthMintsKey || !xai.OAuthDeviceFlow {
|
||||
t.Fatalf("xai oauth flags wrong: %+v", xai)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthProvidersReturnsIndependentClones(t *testing.T) {
|
||||
first := OAuthProviders()
|
||||
mutated := 0
|
||||
for i := range first {
|
||||
for j := range first[i].AuthEnvVars {
|
||||
first[i].AuthEnvVars[j] = "MUTATED"
|
||||
mutated++
|
||||
}
|
||||
}
|
||||
if mutated == 0 {
|
||||
t.Fatal("expected at least one OAuth descriptor with AuthEnvVars to mutate")
|
||||
}
|
||||
// A fresh call must not observe the in-place mutation: like the other catalog
|
||||
// accessors, OAuthProviders must hand back clones, not shared backing slices.
|
||||
for _, d := range OAuthProviders() {
|
||||
for _, env := range d.AuthEnvVars {
|
||||
if env == "MUTATED" {
|
||||
t.Fatalf("OAuthProviders() leaked a shared AuthEnvVars slice for %q", d.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOAuthProvidersNotFlagged(t *testing.T) {
|
||||
for _, d := range All() {
|
||||
switch d.ID {
|
||||
case "openrouter", "xai":
|
||||
continue
|
||||
}
|
||||
if d.OAuth {
|
||||
t.Fatalf("provider %q should not be flagged OAuth-capable", d.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package provideroauth implements provider-specific OAuth login flows that don't
|
||||
// fit the generic env-driven engine in internal/oauth — notably OpenRouter's
|
||||
// bespoke PKCE flow, which mints a normal API key rather than returning an OAuth
|
||||
// bearer token. These flows are launched from the setup wizard / CLI so a user
|
||||
// can "log in with the browser" instead of pasting a key.
|
||||
package provideroauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
const (
|
||||
openRouterDefaultBaseURL = "https://openrouter.ai"
|
||||
openRouterAuthPath = "/auth"
|
||||
openRouterKeyExchangePath = "/api/v1/auth/keys"
|
||||
openRouterMaxBody = 1 << 20
|
||||
)
|
||||
|
||||
// OpenRouterOptions configures the OpenRouter login flow.
|
||||
type OpenRouterOptions struct {
|
||||
// BaseURL overrides https://openrouter.ai (for tests).
|
||||
BaseURL string
|
||||
// HTTPClient performs the key exchange; nil => a client with a sane timeout.
|
||||
HTTPClient *http.Client
|
||||
// OpenBrowser is invoked with the authorize URL. When nil the URL is only
|
||||
// printed (to Out) for the user to open manually.
|
||||
OpenBrowser func(authURL string) error
|
||||
// Out receives the "open this URL" line; nil => the URL is not printed.
|
||||
Out io.Writer
|
||||
// Timeout bounds the whole interactive login; 0 => 5 minutes.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// OpenRouterLogin runs OpenRouter's loopback PKCE flow and returns a freshly
|
||||
// minted OpenRouter API key. The flow needs no client_id: a local callback
|
||||
// captures the authorization code, which is exchanged (with the PKCE verifier)
|
||||
// at /api/v1/auth/keys for a key. PKCE (S256) binds the code to this client, so
|
||||
// no separate CSRF state is required. The returned key is a normal credential
|
||||
// the caller stores as the provider's API key.
|
||||
func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
|
||||
if base == "" {
|
||||
base = openRouterDefaultBaseURL
|
||||
}
|
||||
client := opts.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
pkce, err := oauth.NewPKCE()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("provideroauth: start loopback listener: %w", err)
|
||||
}
|
||||
defer func() { _ = listener.Close() }()
|
||||
callbackURL := fmt.Sprintf("http://%s/callback", listener.Addr().String())
|
||||
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/callback" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if code := strings.TrimSpace(r.URL.Query().Get("code")); code != "" {
|
||||
_, _ = io.WriteString(w, "OpenRouter authorization complete. You may close this window.")
|
||||
select {
|
||||
case codeCh <- code:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
|
||||
select {
|
||||
case errCh <- errors.New("provideroauth: callback missing authorization code"):
|
||||
default:
|
||||
}
|
||||
})}
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
defer func() {
|
||||
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelShutdown()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
authURL := base + openRouterAuthPath + "?" + url.Values{
|
||||
"callback_url": {callbackURL},
|
||||
"code_challenge": {pkce.Challenge},
|
||||
"code_challenge_method": {oauth.MethodS256},
|
||||
}.Encode()
|
||||
if opts.Out != nil {
|
||||
fmt.Fprintf(opts.Out, "Open this URL to authorize OpenRouter:\n %s\n", authURL)
|
||||
}
|
||||
if opts.OpenBrowser != nil {
|
||||
if err := opts.OpenBrowser(authURL); err != nil {
|
||||
return "", fmt.Errorf("provideroauth: open authorization URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var code string
|
||||
select {
|
||||
case code = <-codeCh:
|
||||
case err := <-errCh:
|
||||
return "", err
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("provideroauth: timed out waiting for OpenRouter authorization: %w", ctx.Err())
|
||||
}
|
||||
|
||||
return openRouterExchange(ctx, client, base, code, pkce.Verifier)
|
||||
}
|
||||
|
||||
// openRouterExchange swaps the authorization code + PKCE verifier for an API key.
|
||||
func openRouterExchange(ctx context.Context, client *http.Client, base, code, verifier string) (string, error) {
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"code_challenge_method": oauth.MethodS256,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+openRouterKeyExchangePath, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("provideroauth: openrouter key exchange: %w", err)
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, openRouterMaxBody))
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("provideroauth: openrouter key exchange returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var parsed struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("provideroauth: decode openrouter key response: %w", err)
|
||||
}
|
||||
key := strings.TrimSpace(parsed.Key)
|
||||
if key == "" {
|
||||
return "", errors.New("provideroauth: openrouter returned an empty key")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package provideroauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
|
||||
// callback_url with the given code (or no code), mimicking OpenRouter's redirect.
|
||||
// It also asserts the PKCE params are present on the authorize URL.
|
||||
func simulateBrowser(t *testing.T, code string) func(string) error {
|
||||
t.Helper()
|
||||
return func(authURL string) error {
|
||||
u, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("authorize URL missing PKCE: %s", authURL)
|
||||
}
|
||||
cb := q.Get("callback_url")
|
||||
if cb == "" {
|
||||
t.Fatalf("authorize URL missing callback_url: %s", authURL)
|
||||
}
|
||||
target := cb
|
||||
if code != "" {
|
||||
target = cb + "?code=" + url.QueryEscape(code)
|
||||
}
|
||||
resp, err := http.Get(target) //nolint:noctx // test loopback
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterLoginMintsKey(t *testing.T) {
|
||||
var gotCode, gotVerifier, gotMethod string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/auth/keys" || r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body)
|
||||
gotCode, gotVerifier, gotMethod = body["code"], body["code_verifier"], body["code_challenge_method"]
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
key, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL,
|
||||
HTTPClient: srv.Client(),
|
||||
OpenBrowser: simulateBrowser(t, "TESTCODE"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenRouterLogin: %v", err)
|
||||
}
|
||||
if key != "sk-or-mock-123" {
|
||||
t.Fatalf("key = %q, want sk-or-mock-123", key)
|
||||
}
|
||||
if gotCode != "TESTCODE" || gotVerifier == "" || gotMethod != "S256" {
|
||||
t.Fatalf("exchange body: code=%q verifier=%q method=%q", gotCode, gotVerifier, gotMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterLoginMissingCodeErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL,
|
||||
HTTPClient: srv.Client(),
|
||||
OpenBrowser: simulateBrowser(t, ""), // callback with no code
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a callback without a code should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterLoginExchangeFailureErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden) // exchange rejected
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL,
|
||||
HTTPClient: srv.Client(),
|
||||
OpenBrowser: simulateBrowser(t, "C"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a non-2xx exchange should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterLoginEmptyKeyErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"key":""}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: srv.URL,
|
||||
HTTPClient: srv.Client(),
|
||||
OpenBrowser: simulateBrowser(t, "C"),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty key") {
|
||||
t.Fatalf("empty key should error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
|
||||
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
|
||||
BaseURL: "http://127.0.0.1:1",
|
||||
OpenBrowser: func(string) error { return errors.New("no browser") },
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a browser-open failure should propagate")
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,10 @@ type Options struct {
|
||||
CustomHeaders map[string]string
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
|
||||
// is preferred over APIKey; a nil resolver (or one that yields ok=false) uses
|
||||
// the API key. See providerio.SendWithAuthRetry.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
// StreamIdleTimeout aborts the stream if no data arrives for this long.
|
||||
// Zero uses defaultStreamIdleTimeout.
|
||||
StreamIdleTimeout time.Duration
|
||||
@@ -104,6 +108,7 @@ type Provider struct {
|
||||
authScheme string
|
||||
authHeaderValue string
|
||||
customHeaders map[string]string
|
||||
oauthResolver providerio.TokenResolver
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
@@ -142,6 +147,7 @@ func New(options Options) (*Provider, error) {
|
||||
authScheme: strings.TrimSpace(options.AuthScheme),
|
||||
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
|
||||
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
|
||||
oauthResolver: options.OAuthResolver,
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
streamIdleTimeout: idleTimeout,
|
||||
@@ -176,24 +182,26 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
defer cancelStream()
|
||||
|
||||
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body, func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("anthropic-version", provider.version)
|
||||
if provider.beta != "" {
|
||||
request.Header.Set("anthropic-beta", provider.beta)
|
||||
}
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
|
||||
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body,
|
||||
providerio.AuthHeaders{
|
||||
APIKey: provider.apiKey,
|
||||
DefaultAuthHeader: "x-api-key",
|
||||
AuthHeader: provider.authHeader,
|
||||
AuthScheme: provider.authScheme,
|
||||
AuthHeaderValue: provider.authHeaderValue,
|
||||
CustomHeaders: provider.customHeaders,
|
||||
})
|
||||
}, 0)
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("anthropic-version", provider.version)
|
||||
if provider.beta != "" {
|
||||
request.Header.Set("anthropic-beta", provider.beta)
|
||||
}
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
}
|
||||
}, 0)
|
||||
if err != nil {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/providers/anthropic"
|
||||
"github.com/Gitlawb/zero/internal/providers/gemini"
|
||||
"github.com/Gitlawb/zero/internal/providers/openai"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,10 @@ type Options struct {
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
ModelRegistry *modelregistry.Registry
|
||||
// OAuthResolver, when set, lets the provider authenticate model calls with an
|
||||
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
|
||||
// Applied to the OpenAI and Anthropic providers.
|
||||
OAuthResolver providerio.TokenResolver
|
||||
}
|
||||
|
||||
// New creates a runtime provider for a resolved provider profile.
|
||||
@@ -37,9 +42,11 @@ 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,
|
||||
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
|
||||
})
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return anthropic.New(anthropic.Options{
|
||||
@@ -50,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,
|
||||
@@ -72,6 +80,40 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
}
|
||||
}
|
||||
|
||||
func parseThinkTagsForProfile(profile config.ProviderProfile, resolved resolvedProfile) bool {
|
||||
if profile.ParseThinkTags != nil {
|
||||
return *profile.ParseThinkTags
|
||||
}
|
||||
if resolved.providerKind != config.ProviderKindOpenAICompatible {
|
||||
return false
|
||||
}
|
||||
return modelMayEmitThinkTags(resolved.apiModel)
|
||||
}
|
||||
|
||||
func modelMayEmitThinkTags(model string) bool {
|
||||
model = strings.ToLower(strings.TrimSpace(model))
|
||||
for _, marker := range []string{
|
||||
"deepseek-r1",
|
||||
"deepseek-reasoner",
|
||||
"gpt-oss",
|
||||
"glm-z1",
|
||||
"kimi-k2-thinking",
|
||||
"magistral",
|
||||
"minimax-m3",
|
||||
"nemotron",
|
||||
"qwen3",
|
||||
"qwq",
|
||||
"reasoner",
|
||||
"reasoning",
|
||||
"thinking",
|
||||
} {
|
||||
if strings.Contains(model, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type resolvedProfile struct {
|
||||
providerKind config.ProviderKind
|
||||
apiModel string
|
||||
|
||||
@@ -108,6 +108,33 @@ func TestNewSupportsOpenAIProviderKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseThinkTagsForProfileUsesConservativeDefaultsAndOverride(t *testing.T) {
|
||||
openAICompatible := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "qwen3-coder:480b"}
|
||||
if !parseThinkTagsForProfile(config.ProviderProfile{}, openAICompatible) {
|
||||
t.Fatal("qwen3 OpenAI-compatible model should parse inline think tags by default")
|
||||
}
|
||||
|
||||
generic := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "factory-model"}
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{}, generic) {
|
||||
t.Fatal("generic OpenAI-compatible model should preserve literal think tags by default")
|
||||
}
|
||||
|
||||
official := resolvedProfile{providerKind: config.ProviderKindOpenAI, apiModel: "gpt-4.1"}
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{}, official) {
|
||||
t.Fatal("official OpenAI model should preserve literal think tags by default")
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if !parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &enabled}, generic) {
|
||||
t.Fatal("explicit parseThinkTags=true should enable inline think parsing")
|
||||
}
|
||||
|
||||
disabled := false
|
||||
if parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &disabled}, openAICompatible) {
|
||||
t.Fatal("explicit parseThinkTags=false should disable inline think parsing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
|
||||
transport := &captureTransport{
|
||||
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
|
||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user