Compare commits

..
Author SHA1 Message Date
KRATOS e018ee27e0 Remove superseded and dead utility functions
Behavior-preserving cleanup of functions unreachable even from tests:
- tools.CoreWriteTools / CoreShellTools — superseded by the …Scoped variants.
- workspaceindex.MaxFileDepth — unused helper.
- tui.indentText — unused string util.
- tuiTheme.onPanel2 + the now-orphaned colorPanel2 const — unused style helper.

deadcode -test=false: 114 -> 109; truly-dead: 23 -> 18. Scoped to genuine cruft only —
the remaining dead funcs are dormant/intended-but-unwired features (owner wire-vs-delete),
not blind deletes.
2026-06-14 12:22:32 +05:30
258 changed files with 2041 additions and 31278 deletions

No files matched your search

+11 -306
View File
@@ -1,311 +1,16 @@
---
description: Project-level instruction set for the AI agent working in this repo. Ten discrete modes covering the recurring engineering workflows on Zero.
description: Use Go-native project commands; Node is only for the npm wrapper.
globs: "*.go, *.js, package.json, .github/workflows/*.yml, docs/*.md"
alwaysApply: false
---
# AGENTS.md
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.
Default to Go commands in this repository.
- 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`.
-184
View File
@@ -1,184 +0,0 @@
# OAuth logins & using ChatGPT / Claude subscriptions
Zero supports two distinct things people mean by "log in with OAuth":
1. **OAuth login for a provider/gateway** that issues a *standard* bearer token —
fully built in (`zero auth login …`). Zero attaches the token to model calls
automatically.
2. **Using a ChatGPT or Claude *subscription*** (Plus / Pro / Max) instead of a
pay-per-token API key — only possible through a **local proxy**, for the
reasons documented below. Zero ships a convenience preset and this recipe.
---
## 1. OAuth login for a provider or gateway (built in)
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
`Authorization: Bearer …` on its API, configure it with `ZERO_OAUTH_<NAME>_*`
env vars and log in:
```sh
export ZERO_OAUTH_ACME_CLIENT_ID=…
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
export ZERO_OAUTH_ACME_SCOPES="openid profile"
zero auth login acme # browser (loopback); --device for headless
zero auth status
```
When a login exists for a provider, the **OpenAI and Anthropic** providers send
`Authorization: Bearer <fresh-token>` (auto-refreshed; one refresh-and-retry on a
`401`) instead of the API key. With no login they use the API key exactly as
before. Tokens are stored 0600 (or the OS keyring with
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.
### In the setup wizard (`/provider`)
Running `/provider` opens a **"How do you want to connect?"** chooser:
```text
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
```
Pick **Sign in with OAuth** → the list of providers that do real OAuth → choose one:
```text
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
```
- **OpenRouter / xAI** are real OAuth: your browser opens to approve → done (no
key to paste). OpenRouter mints a key; xAI stores a refreshable token. The same
chooser appears in first-run onboarding. (xAI uses an opt-in preset — set
`ZERO_OAUTH_ALLOW_PRESETS=1` or your own `ZERO_OAUTH_XAI_*`; see below.)
- **Device code (headless / SSH):** for a provider that supports it (xAI), press
**d** on the list to get a code to enter on another device instead of opening a
browser. On an SSH session or headless Linux box (no `DISPLAY`) device code is
used automatically; set `ZERO_OAUTH_DEVICE=1` to force it anywhere. The CLI
equivalent is `zero auth login xai --device`.
- **ChatGPT / Claude are intentionally not in this list** — they can't do in-app
OAuth (see §2). Use *Paste an API key / browse providers* + a local proxy
(`chatgpt-proxy` / `custom-anthropic-compatible`), as described below.
### Built-in OAuth providers
- **OpenRouter (no env needed)** — `zero auth openrouter` opens a browser, you
approve, and it **mints an OpenRouter API key** (public PKCE flow, no client_id).
In the interactive setup wizard, pick **OpenRouter** and press **ctrl+o** at the
key step to do the same inline ("Log in with OAuth"). The minted key is saved to
the provider profile and used normally.
- **xAI (Grok) — opt-in preset** — xAI's flow needs an OAuth `client_id`. Zero
ships a built-in preset for the public Grok-CLI client, but to keep third-party
client identities out of the default credential path it is **off by default**.
Enable it with `export ZERO_OAUTH_ALLOW_PRESETS=1`, then `zero auth login xai`
(browser, or `--device` for headless) works one-click; the token is used directly
on `api.x.ai/v1`. Without the opt-in, set `ZERO_OAUTH_XAI_CLIENT_ID` (and
endpoints, or an issuer) yourself via `ZERO_OAUTH_XAI_*`. Either way the preset is
fully overridable by `ZERO_OAUTH_XAI_*` (env wins), and it requires a
SuperGrok / X Premium+ subscription; the client_id is an undocumented public
Grok-CLI client that may change without notice.
Any field of a preset is overridable via `ZERO_OAUTH_<NAME>_*`. For a fully custom
OAuth/OIDC provider, set those env vars (see `zero auth --help`) and
`zero auth login <name>`.
---
## 2. ChatGPT / Claude subscriptions — why a proxy is required
We researched this carefully. As of mid-2026, a **subscription** OAuth token does
**not** work as a drop-in bearer against the standard APIs:
- **OpenAI (ChatGPT):** a "Sign in with ChatGPT" token only works against
ChatGPT's own backend (`chatgpt.com/backend-api/codex/responses`, the Responses
API), **not** `api.openai.com`. That backend is **Cloudflare bot-protected** —
non-browser / headless clients get `cf-mitigated: challenge` → `403`. It also
requires mimicking the official Codex client (originator + account-id header).
- **Anthropic (Claude):** the Messages API **rejects** subscription OAuth tokens
for third-party use unless the request spoofs the Claude Code identity
(`anthropic-beta: oauth-2025-04-20`, `claude-cli` UA, and a verbatim
*"You are Claude Code…"* system prompt) — and **even then** tool-using requests
on Max plans are routed to a disabled billing lane and `400`. Anthropic's policy
**prohibits** subscription-token use outside Claude Code / claude.ai, and the
timeline hardened through 2026: a **Feb 19 2026** docs update spelled out that
Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK,
then on **April 4 2026** enforcement landed and subscription OAuth tokens
**stopped working in third-party harnesses** (starting with OpenClaw, then the
rest). As of mid-2026 the only supported ways to drive Claude from a third-party
tool are a standard **API key** or pay-as-you-go **"Extra Usage"** billing —
both per-token, not the flat subscription. The request to allow subscription use
(claude-code #37205) was closed *"not planned."*
So Zero does **not** call those backends directly or spoof those clients — that
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
The robust, supported pattern is a **local proxy** that holds your subscription
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
`127.0.0.1`. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
just points at it.
### ChatGPT via a local proxy
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
typically listen on `127.0.0.1:10531/v1`). Then use the built-in **`chatgpt-proxy`**
preset (no API key — the proxy authenticates):
```jsonc
// ~/.config/zero/config.json (or ./.zero/config.json)
{
"activeProvider": "chatgpt",
"providers": [
{
"name": "chatgpt",
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
"model": "gpt-5" // whatever model your proxy serves
}
]
}
```
```sh
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
```
### Claude via a local proxy
There is no single canonical Claude OAuth-proxy port, so use the generic
**`custom-anthropic-compatible`** entry pointed at your proxy's Anthropic-compatible
endpoint:
```jsonc
{
"activeProvider": "claude",
"providers": [
{
"name": "claude",
"catalogID": "custom-anthropic-compatible",
"baseURL": "http://localhost:<port>", // your Claude proxy
"apiKey": "unused-by-proxy",
"model": "claude-sonnet-4.5"
}
]
}
```
---
## 3. Supported alternatives (no proxy)
- **API key (recommended, simplest):** set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`
(or per-profile `apiKey`) and use the `openai` / `anthropic` catalog providers.
Bills as API usage.
- **Anthropic subscription automation, sanctioned:** spawn the real `claude` CLI
(e.g. `claude -p …`) as a subprocess — the only path Anthropic recognizes as a
first-class subscription session.
---
## Notes
- The `chatgpt-proxy` base URL / port and model are defaults you override for your
setup; they are not an endorsement of any specific proxy implementation.
- Subscription-via-proxy depends on third-party tools and undocumented vendor
backends; it can break without notice. The API-key path is the stable one.
+22 -21
View File
@@ -1,36 +1,37 @@
module github.com/Gitlawb/zero
go 1.25.0
go 1.24.2
toolchain go1.26.4
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.4
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/sys v0.46.0
mvdan.cc/sh/v3 v3.13.1
golang.org/x/sys v0.38.0
mvdan.cc/sh/v3 v3.11.0
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/go-quicktest/qt v1.102.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.30.0 // indirect
)
+48 -40
View File
@@ -1,33 +1,31 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -36,23 +34,33 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
+2 -39
View File
@@ -90,48 +90,11 @@ func toolFailureStopAnswer(toolName string, count int) string {
"Please check the request or adjust the tool arguments."
}
// The no-output stop answer is assembled from these fixed parts (only the turn
// count varies). IsNoProgressStop matches all three so a legitimate message that
// merely quotes the marker substring is not misclassified as a failed empty run.
const (
noOutputStopPrefix = "Agent stopped after "
noOutputStopMarker = "with no output (no visible text and no tool calls)"
noOutputStopSuffix = "to avoid consuming tokens without making progress."
)
// noOutputStopAnswer is the final answer returned when the no-output guard
// stops the run. The turn count is interpolated at the call site.
func noOutputStopAnswer(turns int) string {
return noOutputStopPrefix + strconv.Itoa(turns) + " turns " + noOutputStopMarker + " " + noOutputStopSuffix
}
// IsNoProgressStop reports whether content IS the no-output guardrail stop answer
// (a run that produced no visible text and no tool calls). It matches the EXACT
// structure noOutputStopAnswer emits — prefix + "<int> turns " + marker + " " +
// suffix, where only the integer turn count varies — rather than just looking for
// the three parts in order. A loose check (prefix && contains-marker && suffix)
// would misclassify a genuine assistant/tool message that merely quotes the
// marker amid other prose, which would wrongly hide a real session from /resume
// and skip its title generation.
func IsNoProgressStop(content string) bool {
trimmed := strings.TrimSpace(content)
if !strings.HasPrefix(trimmed, noOutputStopPrefix) {
return false
}
rest := trimmed[len(noOutputStopPrefix):]
const turnsSep = " turns "
sep := strings.Index(rest, turnsSep)
if sep < 0 {
return false
}
// The text between the prefix and " turns " must be exactly the bare integer
// count; anything else means this isn't the guard's own answer.
if _, err := strconv.Atoi(rest[:sep]); err != nil {
return false
}
// The marker must be immediately followed (one space) by the suffix and then
// end — no arbitrary text wedged in between.
return rest[sep+len(turnsSep):] == noOutputStopMarker+" "+noOutputStopSuffix
return "Agent stopped after " + strconv.Itoa(turns) +
" turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."
}
// Reminder markers are stable substrings used both to build the reminder text
-36
View File
@@ -1,36 +0,0 @@
package agent
import "testing"
func TestIsNoProgressStopRequiresFullStructure(t *testing.T) {
// The actual guardrail answer (any turn count) is recognized.
for _, turns := range []int{1, 3, 12} {
if !IsNoProgressStop(noOutputStopAnswer(turns)) {
t.Fatalf("the real no-output stop answer (turns=%d) should be recognized", turns)
}
}
// A legitimate message that merely QUOTES the marker must NOT be classified as
// a no-progress stop — that would wrongly hide a real session from /resume.
rejected := []string{
`The previous run ended "with no output (no visible text and no tool calls)" so here is what I did instead.`,
"with no output (no visible text and no tool calls)", // bare marker, no prefix/suffix
"Agent stopped after 3 turns.", // prefix only
"to avoid consuming tokens without making progress.", // suffix only
"Here is your e-commerce site.", // unrelated
"",
// Hostile input: prefix + marker + suffix ALL present, but the marker is
// quoted mid-sentence rather than being the guard's own structured answer.
// A loose prefix/contains/suffix check misclassifies this.
`Agent stopped after 3 turns. The marker is "with no output (no visible text and no tool calls)" so here it is: to avoid consuming tokens without making progress.`,
// Arbitrary text wedged between the marker and the suffix.
"Agent stopped after 3 turns with no output (no visible text and no tool calls) and then some more text to avoid consuming tokens without making progress.",
// Non-integer "turn count" between the prefix and the marker.
"Agent stopped after several turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress.",
}
for _, content := range rejected {
if IsNoProgressStop(content) {
t.Fatalf("content must not be classified as a no-progress stop: %q", content)
}
}
}
-11
View File
@@ -71,17 +71,6 @@ work.
managers). It is not a substitute for the file tools.
- Treat tool output as ground truth. If a command fails, read the error, form a
hypothesis, and address the root cause; do not retry the same call blindly.
- When a web search or fetch tool is available, search the web before answering
about an external entity, product, library, model, company, version, or recent
release you do not recognize. Do not guess, and do not assume the most common
meaning of an ambiguous name — use the conversation's domain (this is an AI /
software tool) to disambiguate. If the first results look off-topic, refine the
query and search again before answering; never reply that you don't know without
searching first.
- Do not web-search timeless facts, fundamentals, or anything answerable from the
workspace — read the code with the file tools instead. Keep queries short (a few
words), start broad then narrow, and scale the number of searches to the
question: one for a single fact, a few for a deeper answer.
## Permission and safety
-2
View File
@@ -19,8 +19,6 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"final response",
} {
if !strings.Contains(prompt, want) {
+4
View File
@@ -186,6 +186,10 @@ func validateFileList(taskPath string, field string, files []string, required bo
return problems
}
func validateExpectedChangedFiles(taskPath string, files []string) []string {
return validateFileList(taskPath, "expectedChangedFiles", files, true)
}
func validateStringList(taskPath string, field string, values []string) []string {
problems := []string{}
seen := map[string]int{}
-38
View File
@@ -1,38 +0,0 @@
// Package browser opens a URL in the user's default web browser. It is used by
// interactive flows (e.g. the provider setup wizard's OAuth login) that need to
// hand the user off to a browser for authorization.
package browser
import (
"fmt"
"os/exec"
"runtime"
)
// OpenURL launches url in the default browser. It returns once the launcher
// process has started (it does not wait for the browser to close). A failure to
// start the launcher is returned so callers can fall back to printing the URL.
func OpenURL(url string) error {
name, args := openCommand(runtime.GOOS, url)
cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("browser: open url: %w", err)
}
// Reap the short-lived launcher (open/xdg-open/rundll32 exit promptly) so it
// does not linger as a zombie; the browser window itself is independent.
go func() { _ = cmd.Wait() }()
return nil
}
// openCommand returns the launcher command + args for a platform. Split out as a
// pure function so the per-OS choice is unit-testable without spawning anything.
func openCommand(goos, url string) (string, []string) {
switch goos {
case "darwin":
return "open", []string{url}
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
default: // linux and other unixes
return "xdg-open", []string{url}
}
}
-27
View File
@@ -1,27 +0,0 @@
package browser
import (
"reflect"
"testing"
)
func TestOpenCommand(t *testing.T) {
cases := []struct {
goos string
name string
args []string
}{
{"darwin", "open", []string{"https://x.test"}},
{"linux", "xdg-open", []string{"https://x.test"}},
{"freebsd", "xdg-open", []string{"https://x.test"}},
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x.test"}},
}
for _, tc := range cases {
t.Run(tc.goos, func(t *testing.T) {
name, args := openCommand(tc.goos, "https://x.test")
if name != tc.name || !reflect.DeepEqual(args, tc.args) {
t.Fatalf("openCommand(%s) = %q %v, want %q %v", tc.goos, name, args, tc.name, tc.args)
}
})
}
}
+20 -83
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
@@ -24,7 +23,6 @@ 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"
@@ -105,10 +103,7 @@ func defaultAppDeps() appDeps {
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return providers.New(profile, providers.Options{
UserAgent: userAgent(),
OAuthResolver: oauthResolverForProfile(profile),
})
return providers.New(profile, providers.Options{UserAgent: userAgent()})
},
probeProviderHealth: providerhealth.Probe,
newSessionStore: func() *sessions.Store {
@@ -245,8 +240,6 @@ 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":
@@ -281,8 +274,6 @@ 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":
@@ -424,26 +415,8 @@ 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 {
@@ -534,24 +507,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
})
lastKnownMCPConfig := mcpConfig
return deps.runTUI(context.Background(), tui.Options{
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,
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,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
@@ -613,37 +582,12 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
return registry
}
// 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) {
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
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
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
@@ -665,17 +609,12 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
}
}
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
if runtime == nil {
return
}
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)
}
if err := runtime.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
}
}
@@ -698,7 +637,6 @@ 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
@@ -717,7 +655,6 @@ 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
-35
View File
@@ -8,7 +8,6 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -60,38 +59,10 @@ 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
@@ -133,12 +104,6 @@ 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)
}
-403
View File
@@ -1,403 +0,0 @@
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
}
-158
View File
@@ -1,158 +0,0 @@
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())
}
}
}
-122
View File
@@ -17,13 +17,6 @@ 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())
@@ -51,44 +44,6 @@ 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 {
@@ -104,21 +59,6 @@ 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 {
@@ -146,32 +86,6 @@ 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)))
@@ -221,52 +135,16 @@ 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
}
-222
View File
@@ -5,12 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -158,220 +153,3 @@ 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)
}
}
-672
View File
@@ -1,672 +0,0 @@
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
}
-123
View File
@@ -1,123 +0,0 @@
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)
}
}
}
+3 -15
View File
@@ -19,6 +19,7 @@ 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"
@@ -162,7 +163,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
var specialistRuntime *agentToolRuntime
var specialistRuntime *specialist.Runtime
if shouldRegisterExecSpecialistTools(options) {
var err error
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
@@ -367,12 +368,6 @@ 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,
@@ -709,8 +704,7 @@ 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), plus the
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
// the default policy: the autonomy ceiling and the network mode.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
if network := strings.TrimSpace(cfg.Network); network != "" {
@@ -727,12 +721,6 @@ 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
}
-47
View File
@@ -1,47 +0,0 @@
package cli
import (
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestProviderHasOAuthLogin(t *testing.T) {
xai := config.ProviderProfile{Name: "xai", CatalogID: "xai"}
if providerHasOAuthLogin(xai, nil) {
t.Fatal("no stored login must be false")
}
if !providerHasOAuthLogin(xai, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by name must be true")
}
if !providerHasOAuthLogin(config.ProviderProfile{Name: "grok", CatalogID: "xai"}, map[string]bool{"xai": true}) {
t.Fatal("a stored login keyed by catalog id must be true")
}
}
func TestSetupRequiredRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed token: %v", err)
}
// xai has no inline key (env-only) but IS logged in via OAuth → no setup.
loggedIn := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}}
if setupRequired(loggedIn) {
t.Fatal("a provider with a stored OAuth login must not require onboarding")
}
// A keyless provider with no OAuth login still requires setup.
noLogin := config.ResolvedConfig{Provider: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"}}
if !setupRequired(noLogin) {
t.Fatal("a keyless provider with no OAuth login must require onboarding")
}
}
-67
View File
@@ -1,67 +0,0 @@
package cli
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// providerOAuthName resolves the OAuth login name for a provider profile. It is
// the name a user logs in under (`zero auth login <name>`) whose token then
// authenticates this provider's model calls. For now it is the profile name;
// the provider presets map well-known kinds (openai/anthropic) to canonical
// names on top of this.
func providerOAuthName(profile config.ProviderProfile) string {
return strings.TrimSpace(profile.Name)
}
// oauthResolverForProfile returns a TokenResolver that authenticates a provider's
// model calls with the user's OAuth login, or nil when no login exists for this
// provider. Returning nil for the no-login case keeps API-key users free of any
// per-request store lookups: the resolver is only attached once a login is
// present at construction time.
func oauthResolverForProfile(profile config.ProviderProfile) providerio.TokenResolver {
name := providerOAuthName(profile)
if name == "" {
return nil
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil
}
key := oauth.ProviderKey(name)
if _, ok, loadErr := store.Load(key); loadErr != nil || !ok {
// No login (or an unreadable/invalid key) → API-key auth, no resolver.
return nil
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
if err != nil {
return nil
}
return func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
}
-13
View File
@@ -662,19 +662,6 @@ 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{
+2 -104
View File
@@ -3,13 +3,11 @@ package cli
import (
"fmt"
"io"
"net/url"
"strconv"
"strings"
"unicode"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/provideronboarding"
@@ -287,108 +285,8 @@ func setupRequired(resolved config.ResolvedConfig) bool {
if !config.HasProviderProfile(resolved.Provider) {
return true
}
if _, missing := setupMissingCredentialEnv(resolved.Provider); !missing {
return false
}
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
// though the profile has no inline key / env var — so a logged-in provider
// must not trigger onboarding.
return !providerHasOAuthLogin(resolved.Provider, oauthLoggedInProviders())
}
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
// provider, keyed by its profile name or catalog id.
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range []string{strings.TrimSpace(profile.Name), strings.TrimSpace(profile.CatalogID)} {
if name != "" && oauthLogins[name] {
return true
}
}
return false
}
// oauthLoggedInProviders returns the set of provider names that have a stored
// OAuth token, so credential checks recognize an OAuth login (not just inline
// keys / env vars). Errors degrade to an empty set (no logins).
func oauthLoggedInProviders() map[string]bool {
out := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return out
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return out
}
for _, status := range statuses {
if status.HasToken {
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
}
}
return out
}
// firstUsableProvider returns the saved provider best suited to run without
// onboarding: the first usable (inline credential present, or no-auth/local)
// non-local provider, else the first usable local one. It lets the CLI fall back
// to an already-configured login when the active provider happens to lack a
// credential, instead of re-running onboarding every launch.
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
var localFallback config.ProviderProfile
haveLocal := false
for _, profile := range providers {
if !config.HasProviderProfile(profile) {
continue
}
// A profile whose catalog entry no longer resolves AND that has no explicit
// BaseURL has no endpoint to talk to, so it cannot become a working
// provider — skip it rather than picking a fallback that fails at first use.
// (A stale CatalogID with a BaseURL still works as a custom endpoint.)
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" && strings.TrimSpace(profile.BaseURL) == "" {
if _, err := providercatalog.Require(catalogID); err != nil {
continue
}
}
if _, missing := setupMissingCredentialEnv(profile); missing {
continue
}
if providerProfileIsLocal(profile) {
if !haveLocal {
localFallback = profile
haveLocal = true
}
continue
}
return profile, true
}
if haveLocal {
return localFallback, true
}
return config.ProviderProfile{}, false
}
// providerProfileIsLocal reports whether a provider points at a local endpoint
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
// remote keyed provider that is more likely reachable.
func providerProfileIsLocal(profile config.ProviderProfile) bool {
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
if descriptor, err := providercatalog.Require(catalogID); err == nil && descriptor.Local {
return true
}
}
base := strings.TrimSpace(profile.BaseURL)
if base == "" {
return false
}
parsed, err := url.Parse(base)
if err != nil {
return false
}
switch strings.ToLower(parsed.Hostname()) {
case "localhost", "127.0.0.1", "::1":
return true
}
return false
_, missing := setupMissingCredentialEnv(resolved.Provider)
return missing
}
func formatSetupComplete(result tui.SetupResult) string {
-91
View File
@@ -1,91 +0,0 @@
package cli
import (
"testing"
"github.com/Gitlawb/zero/internal/config"
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable (env only, no inline key)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "moonshot" {
t.Fatalf("want remote keyed provider (moonshot), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "ollama" {
t.Fatalf("want local usable fallback (ollama), got %q ok=%v", got.Name, ok)
}
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
}
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("no provider has a credential, want ok=false, got %q", got.Name)
}
}
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want keyless local proxy fallback, got %q ok=%v", got.Name, ok)
}
}
// A profile whose catalog entry no longer resolves and that carries no explicit
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "custom" {
t.Fatalf("want custom-endpoint fallback, got %q ok=%v", got.Name, ok)
}
}
func TestProviderProfileIsLocal(t *testing.T) {
cases := []struct {
name string
baseURL string
want bool
}{
{"loopback name", "http://localhost:11434/v1", true},
{"loopback v4", "http://127.0.0.1:8080", true},
{"loopback v6", "http://[::1]:10531/v1", true},
{"remote", "https://api.moonshot.ai/v1", false},
{"contains-localhost-substring", "https://notlocalhost.com/v1", false},
{"host-with-127-substring", "https://api127.0.0.1.example.com", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := providerProfileIsLocal(config.ProviderProfile{BaseURL: tc.baseURL})
if got != tc.want {
t.Fatalf("providerProfileIsLocal(%q) = %v, want %v", tc.baseURL, got, tc.want)
}
})
}
}
+1 -26
View File
@@ -1094,8 +1094,7 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
"auth_scheme": "raw",
"auth_header_value": "header-secret",
"custom_headers": {"X-Zero": "1"},
"model_id": "custom-model",
"parse_think_tags": true
"model_id": "custom-model"
}]
}`)
@@ -1120,30 +1119,6 @@ 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) {
-14
View File
@@ -34,7 +34,6 @@ 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"`
}
@@ -52,7 +51,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
profile.CustomHeaders != nil ||
strings.TrimSpace(profile.Model) != "" ||
profile.ParseThinkTags != nil ||
strings.TrimSpace(profile.Description) != ""
}
@@ -306,8 +304,6 @@ 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"`
}
@@ -329,7 +325,6 @@ 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
}
@@ -353,15 +348,6 @@ 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
-37
View File
@@ -72,43 +72,6 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
model = strings.TrimSpace(model)
if model == "" {
return FileConfig{}, fmt.Errorf("model is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(cfg.Providers[index].Name, name) {
cfg.Providers[index].Model = model
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
func SetFavoriteModels(path string, models []string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
-66
View File
@@ -150,72 +150,6 @@ func TestSetActiveProviderTightensExistingConfigFilePermissions(t *testing.T) {
}
}
func TestSetProviderModelUpdatesConfiguredProvider(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{
Name: "openai",
ProviderKind: ProviderKindOpenAI,
APIKey: "sk-test",
Model: "gpt-4.1",
},
{
Name: "anthropic",
ProviderKind: ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
},
},
}, 0o600)
cfg, err := SetProviderModel(path, " OpenAI ", " gpt-4.1-mini ")
if err != nil {
t.Fatalf("SetProviderModel() error = %v", err)
}
if cfg.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("updated provider model = %q, want gpt-4.1-mini", cfg.Providers[0].Model)
}
if cfg.Providers[0].APIKey != "sk-test" {
t.Fatalf("provider credential was not preserved: %#v", cfg.Providers[0])
}
if cfg.Providers[1].Model != "claude-sonnet-4.5" {
t.Fatalf("unrelated provider changed: %#v", cfg.Providers[1])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Model != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", persisted.Providers[0].Model)
}
if persisted.ActiveProvider != "openai" {
t.Fatalf("active provider changed to %q", persisted.ActiveProvider)
}
}
func TestSetProviderModelRejectsUnknownProviderWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "openai",
Providers: []ProviderProfile{
{Name: "openai", ProviderKind: ProviderKindOpenAI, Model: "gpt-4.1"},
},
}, 0o600)
_, err := SetProviderModel(path, "anthropic", "claude-sonnet-4.5")
if err == nil {
t.Fatal("SetProviderModel() error = nil, want error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider\nbefore: %s\nafter: %s", before, after)
}
}
func TestUpsertProviderTightensExistingConfigFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not expose POSIX mode bits reliably")
-41
View File
@@ -1,41 +0,0 @@
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)
}
-38
View File
@@ -1,38 +0,0 @@
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)
}
}
}
}
-130
View File
@@ -1,130 +0,0 @@
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
}
-165
View File
@@ -1,165 +0,0 @@
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
}
-102
View File
@@ -1,102 +0,0 @@
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)
}
}
-87
View File
@@ -1,87 +0,0 @@
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)))
}
-22
View File
@@ -1,22 +0,0 @@
//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)
}
-77
View File
@@ -1,77 +0,0 @@
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")
}
}
-27
View File
@@ -1,27 +0,0 @@
//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
}
-42
View File
@@ -1,42 +0,0 @@
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
}
-355
View File
@@ -1,355 +0,0 @@
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
}
-248
View File
@@ -1,248 +0,0 @@
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")
}
-184
View File
@@ -1,184 +0,0 @@
// 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
}
-120
View File
@@ -1,120 +0,0 @@
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)
}
}
}
-169
View File
@@ -1,169 +0,0 @@
// 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
}
-67
View File
@@ -1,67 +0,0 @@
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")
}
}
-255
View File
@@ -1,255 +0,0 @@
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
}
-342
View File
@@ -1,342 +0,0 @@
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")
}
-378
View File
@@ -1,378 +0,0 @@
package remote
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
// gitTimeout bounds a single git invocation (bundle create/verify, clone) so a
// hung git process cannot pin a connection or the upload path indefinitely.
const gitTimeout = 2 * time.Minute
// bundleChunkSize is the per-frame payload when streaming a bundle file. It is
// kept comfortably under daemon.MaxFrameSize (1 MiB).
const bundleChunkSize = 512 << 10
// bundleHeader is the first control frame of a bundle upload (after the auth
// handshake): it declares the link id and the exact byte size that follows.
type bundleHeader struct {
LinkID string `json:"link_id"`
Size int64 `json:"size"`
}
// bundleResult is the bridge's reply once the bundle is received and extracted.
type bundleResult struct {
OK bool `json:"ok"`
Path string `json:"path,omitempty"`
Message string `json:"message,omitempty"`
}
func writeBundleHeader(w io.Writer, h bundleHeader) error {
payload, err := json.Marshal(h)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleHeader(r io.Reader) (bundleHeader, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleHeader{}, err
}
if kind != daemon.KindCtrl {
return bundleHeader{}, errors.New("remote: expected bundle header frame")
}
var h bundleHeader
if err := json.Unmarshal(payload, &h); err != nil {
return bundleHeader{}, fmt.Errorf("remote: decode bundle header: %w", err)
}
return h, nil
}
func writeBundleResult(w io.Writer, res bundleResult) error {
payload, err := json.Marshal(res)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readBundleResult(r io.Reader) (bundleResult, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return bundleResult{}, err
}
if kind != daemon.KindCtrl {
return bundleResult{}, errors.New("remote: expected bundle result frame")
}
var res bundleResult
if err := json.Unmarshal(payload, &res); err != nil {
return bundleResult{}, fmt.Errorf("remote: decode bundle result: %w", err)
}
return res, nil
}
// ---- server side -----------------------------------------------------------
// handleBundle receives an uploaded bundle, extracts it into a per-link working
// tree, and reports the outcome. It always closes conn.
func (b *Bridge) handleBundle(conn net.Conn) {
defer func() { _ = conn.Close() }()
res := b.receiveBundle(conn)
if !res.OK {
b.logf("remote: bundle upload rejected: %s", res.Message)
}
_ = writeBundleResult(conn, res)
}
// receiveBundle reads the header + framed bundle bytes, verifies the bundle, and
// extracts it under bundleDir. Every failure returns a non-OK result rather than
// panicking, and the staged temp file is always removed.
func (b *Bridge) receiveBundle(conn net.Conn) bundleResult {
hdr, err := readBundleHeader(conn)
if err != nil {
return bundleResult{Message: "read bundle header: " + err.Error()}
}
id, err := sanitizeLinkID(hdr.LinkID)
if err != nil {
return bundleResult{Message: err.Error()}
}
if hdr.Size <= 0 || hdr.Size > b.maxBundleBytes {
return bundleResult{Message: fmt.Sprintf("invalid bundle size %d (max %d)", hdr.Size, b.maxBundleBytes)}
}
tmp, err := os.CreateTemp("", "zero-remote-*.bundle")
if err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := streamFramesToFile(conn, tmp, hdr.Size); err != nil {
_ = tmp.Close()
return bundleResult{Message: "receive bundle: " + err.Error()}
}
if err := tmp.Close(); err != nil {
return bundleResult{Message: "stage bundle: " + err.Error()}
}
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleVerify(ctx, tmpName); err != nil {
return bundleResult{Message: "bundle verify: " + err.Error()}
}
dest := filepath.Join(b.bundleDir, id)
if !withinDir(b.bundleDir, dest) {
return bundleResult{Message: "invalid link id"}
}
if err := extractBundle(ctx, tmpName, dest); err != nil {
return bundleResult{Message: "extract bundle: " + err.Error()}
}
return bundleResult{OK: true, Path: dest}
}
// streamFramesToFile copies exactly size bytes from KindData frames on r into w.
// A non-data frame, or any frame that would overrun the declared size, is an
// error (fail closed) so a peer cannot write past the cap.
func streamFramesToFile(r io.Reader, w io.Writer, size int64) error {
remaining := size
for remaining > 0 {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return err
}
if kind != daemon.KindData {
return errors.New("expected bundle data frame")
}
if int64(len(payload)) > remaining {
return errors.New("bundle exceeds declared size")
}
if _, err := w.Write(payload); err != nil {
return err
}
remaining -= int64(len(payload))
}
return nil
}
// extractBundle clones bundleFile into a staging dir, then atomically renames it
// over dest (replacing any prior extraction for this link id). git clone needs a
// non-existent target, so the staging+rename keeps the live dest intact on error.
func extractBundle(ctx context.Context, bundleFile, dest string) error {
parent := filepath.Dir(dest)
if err := os.MkdirAll(parent, 0o700); err != nil {
return err
}
staging, err := os.MkdirTemp(parent, ".staging-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(staging) }()
cloneDest := filepath.Join(staging, "repo")
if err := gitClone(ctx, bundleFile, cloneDest); err != nil {
return err
}
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(cloneDest, dest)
}
// ---- client side -----------------------------------------------------------
// UploadRepoBundle creates a git bundle of repoDir's full history and uploads it
// to the remote bridge over an authenticated, bundle-mode TLS connection. The
// bridge extracts it into a per-link working tree and returns its path, captured
// in the returned SessionLink. repoDir must be a git work tree.
func UploadRepoBundle(cfg RemoteConfig, repoDir, linkID string) (*SessionLink, error) {
id, err := sanitizeLinkID(linkID)
if err != nil {
return nil, err
}
repoDir = strings.TrimSpace(repoDir)
if repoDir == "" {
return nil, errors.New("remote: repo dir is required")
}
if !isGitWorktree(repoDir) {
return nil, fmt.Errorf("remote: %s is not a git repository", repoDir)
}
// Reserve a unique temp name, then let git create the bundle fresh at it.
tmp, err := os.CreateTemp("", "zero-bundle-*.bundle")
if err != nil {
return nil, fmt.Errorf("remote: stage bundle: %w", err)
}
tmpName := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpName)
defer func() { _ = os.Remove(tmpName) }()
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
if err := gitBundleCreate(ctx, repoDir, tmpName); err != nil {
return nil, fmt.Errorf("remote: create bundle: %w", err)
}
sum, size, err := hashFile(tmpName)
if err != nil {
return nil, fmt.Errorf("remote: hash bundle: %w", err)
}
conn, err := dialAuthenticated(cfg, ModeBundle)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
if err := writeBundleHeader(conn, bundleHeader{LinkID: id, Size: size}); err != nil {
return nil, fmt.Errorf("remote: send bundle header: %w", err)
}
if err := streamFileFrames(conn, tmpName); err != nil {
return nil, fmt.Errorf("remote: send bundle: %w", err)
}
res, err := readBundleResult(conn)
if err != nil {
return nil, fmt.Errorf("remote: bundle result: %w", err)
}
if !res.OK {
return nil, fmt.Errorf("remote: bundle rejected: %s", res.Message)
}
return &SessionLink{
Address: strings.TrimSpace(cfg.Address),
ServerName: strings.TrimSpace(cfg.ServerName),
CACertFile: strings.TrimSpace(cfg.CACertFile),
LinkID: id,
RemotePath: res.Path,
BundleSHA256: sum,
}, nil
}
// streamFileFrames writes the file at path to w as a sequence of KindData frames.
func streamFileFrames(w io.Writer, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
buf := make([]byte, bundleChunkSize)
for {
n, err := f.Read(buf)
if n > 0 {
if werr := daemon.WriteFrame(w, daemon.KindData, buf[:n]); werr != nil {
return werr
}
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
// ---- git + path helpers ----------------------------------------------------
func gitBundleCreate(ctx context.Context, repoDir, outFile string) error {
return runGit(ctx, repoDir, "bundle", "create", outFile, "--all")
}
func gitBundleVerify(ctx context.Context, bundleFile string) error {
return runGit(ctx, "", "bundle", "verify", bundleFile)
}
func gitClone(ctx context.Context, bundleFile, destDir string) error {
return runGit(ctx, "", "clone", "--quiet", bundleFile, destDir)
}
// isGitWorktree reports whether dir is inside a git work tree.
func isGitWorktree(dir string) bool {
cmd := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
out, err := cmd.CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) == "true"
}
// runGit runs a git subcommand, returning a concise single-line error on failure.
func runGit(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
if err != nil {
msg := firstLine(strings.TrimSpace(string(out)))
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("git %s: %s", args[0], msg)
}
return nil
}
// sanitizeLinkID validates a link id used as a single path component under the
// bundle dir. It allows letters, digits, '-', '_', '.', forbids the traversal
// names, and caps the length — so it can never escape the bundle dir.
func sanitizeLinkID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", errors.New("remote: link id is required")
}
if len(id) > 128 {
return "", errors.New("remote: link id too long (max 128)")
}
if id == "." || id == ".." {
return "", errors.New("remote: invalid link id")
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
default:
return "", errors.New("remote: link id may only contain letters, digits, '-', '_', '.'")
}
}
return id, nil
}
// withinDir reports whether target resolves to a path inside root.
func withinDir(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// hashFile returns the hex SHA-256 and byte size of the file at path.
func hashFile(path string) (sum string, size int64, err error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
-193
View File
@@ -1,193 +0,0 @@
package remote
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// initTestRepo creates a temp git work tree with one committed file and returns
// its path. It sets a deterministic identity so it does not depend on global git
// config.
func initTestRepo(t *testing.T, file, content string) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run("init", "-q")
run("config", "user.email", "[email protected]")
run("config", "user.name", "t")
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
run("add", ".")
run("commit", "-q", "-m", "init")
return dir
}
func TestGitBundleRoundTrip(t *testing.T) {
repo := initTestRepo(t, "a.txt", "content")
ctx := context.Background()
bundle := filepath.Join(t.TempDir(), "x.bundle")
if err := gitBundleCreate(ctx, repo, bundle); err != nil {
t.Fatalf("create: %v", err)
}
if err := gitBundleVerify(ctx, bundle); err != nil {
t.Fatalf("verify: %v", err)
}
dest := filepath.Join(t.TempDir(), "clone")
if err := gitClone(ctx, bundle, dest); err != nil {
t.Fatalf("clone: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "a.txt")); err != nil {
t.Fatalf("cloned tree missing file: %v", err)
}
}
func TestIsGitWorktree(t *testing.T) {
repo := initTestRepo(t, "f", "x")
if !isGitWorktree(repo) {
t.Fatal("a git repo should be detected")
}
if isGitWorktree(t.TempDir()) {
t.Fatal("a plain dir should not be detected as a git repo")
}
}
func TestSanitizeLinkID(t *testing.T) {
for _, ok := range []string{"proj", "proj-1", "a_b.c", "ABC123"} {
if _, err := sanitizeLinkID(ok); err != nil {
t.Fatalf("sanitizeLinkID(%q) unexpected error: %v", ok, err)
}
}
for _, bad := range []string{"", " ", ".", "..", "a/b", "a\\b", "a b", "a$b", string(make([]byte, 200))} {
if _, err := sanitizeLinkID(bad); err == nil {
t.Fatalf("sanitizeLinkID(%q) should error", bad)
}
}
}
func TestWithinDir(t *testing.T) {
root := t.TempDir()
if !withinDir(root, filepath.Join(root, "child")) {
t.Fatal("child should be within root")
}
if withinDir(root, filepath.Dir(root)) {
t.Fatal("parent should not be within root")
}
}
func TestSessionLinkSaveLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), "link.json")
link := SessionLink{Address: "host:9000", ServerName: "host", LinkID: "proj-1", RemotePath: "/bundles/proj-1", BundleSHA256: "deadbeef"}
if err := link.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("link file perm = %v, want 0600", perm)
}
}
got, err := LoadSessionLink(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if *got != link {
t.Fatalf("roundtrip mismatch: %+v != %+v", *got, link)
}
}
func TestSessionLinkValidate(t *testing.T) {
for _, bad := range []SessionLink{
{LinkID: "p", RemotePath: "/r"}, // no address
{Address: "h:1", RemotePath: "/r"}, // no link id
{Address: "h:1", LinkID: "p"}, // no remote path
} {
if err := bad.Validate(); err == nil {
t.Fatalf("Validate(%+v) should error", bad)
}
if err := bad.Save(filepath.Join(t.TempDir(), "x.json")); err == nil {
t.Fatalf("Save of invalid link %+v should error", bad)
}
}
}
func TestUploadRepoBundleRejectsNonRepo(t *testing.T) {
// A non-git dir is rejected before any dial, so the bogus address is never used.
_, err := UploadRepoBundle(RemoteConfig{Address: "127.0.0.1:1", Token: "t"}, t.TempDir(), "p")
if err == nil {
t.Fatal("a non-git dir should be rejected")
}
}
func TestBridgeBundleUploadRoundTrip(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
bundleRoot := t.TempDir()
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: bundleRoot})
repo := initTestRepo(t, "hello.txt", "hi there")
link, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1")
if err != nil {
t.Fatalf("UploadRepoBundle: %v", err)
}
wantPath := filepath.Join(bundleRoot, "proj-1")
if link.RemotePath != wantPath {
t.Fatalf("remote path = %q, want %q", link.RemotePath, wantPath)
}
if link.BundleSHA256 == "" {
t.Fatal("link should carry a bundle sha256")
}
// The extracted work tree should contain the committed file.
data, err := os.ReadFile(filepath.Join(link.RemotePath, "hello.txt"))
if err != nil {
t.Fatalf("extracted tree missing file: %v", err)
}
if string(data) != "hi there" {
t.Fatalf("extracted file content = %q", data)
}
// A second upload to the same link id replaces the prior extraction.
if _, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "proj-1"); err != nil {
t.Fatalf("re-upload: %v", err)
}
}
func TestBridgeBundleDisabledByDefault(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1}) // no BundleDir
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload must be refused when --bundle-dir is unset")
}
}
func TestBridgeBundleRejectsBadToken(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("correct")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, BundleDir: t.TempDir(), AuthFailDelay: -1})
repo := initTestRepo(t, "f", "x")
_, err := UploadRepoBundle(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca}, repo, "p")
if err == nil {
t.Fatal("bundle upload with a bad token must be refused")
}
}
-125
View File
@@ -1,125 +0,0 @@
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
}
-107
View File
@@ -1,107 +0,0 @@
package remote
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// SessionLink records the association between a local repo and the remote
// working tree a bundle upload produced. It carries everything needed to reach
// the linked repo again — the bridge address, TLS verification details, the link
// id, and the extracted remote path — except the bearer token, which is always
// supplied separately (never persisted to the link file).
type SessionLink struct {
// Address is host:port of the remote bridge.
Address string `json:"address"`
// ServerName overrides TLS/SNI verification; empty => host of Address.
ServerName string `json:"server_name,omitempty"`
// CACertFile is the CA trusted for the bridge cert (for a self-signed bridge).
CACertFile string `json:"ca_cert_file,omitempty"`
// LinkID is the per-link identifier (a single path component on the remote).
LinkID string `json:"link_id"`
// RemotePath is the extracted working tree on the remote; use it as --cwd for
// a remote run/attach against the linked repo.
RemotePath string `json:"remote_path"`
// BundleSHA256 is the hex SHA-256 of the uploaded bundle, for verification.
BundleSHA256 string `json:"bundle_sha256,omitempty"`
}
// Validate checks the fields required to use a link.
func (l SessionLink) Validate() error {
if strings.TrimSpace(l.Address) == "" {
return errors.New("remote: session link address is required")
}
if strings.TrimSpace(l.LinkID) == "" {
return errors.New("remote: session link id is required")
}
if strings.TrimSpace(l.RemotePath) == "" {
return errors.New("remote: session link remote path is required")
}
return nil
}
// Save writes the link to path as pretty JSON with 0600 permissions, atomically
// (write-temp-then-rename) so a reader never sees a partial file.
func (l SessionLink) Save(path string) error {
if err := l.Validate(); err != nil {
return err
}
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return atomicWriteFile(path, append(data, '\n'), 0o600)
}
// LoadSessionLink reads and validates a link file written by Save.
func LoadSessionLink(path string) (*SessionLink, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var l SessionLink
if err := json.Unmarshal(data, &l); err != nil {
return nil, fmt.Errorf("remote: decode session link: %w", err)
}
if err := l.Validate(); err != nil {
return nil, err
}
return &l, nil
}
// atomicWriteFile writes data to path via a temp file in the same directory,
// chmod-ed to perm, then renamed into place.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".link-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Chmod(perm); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, path); err != nil {
return err
}
cleanup = false
return nil
}
-301
View File
@@ -1,301 +0,0 @@
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})
}
-176
View File
@@ -1,176 +0,0 @@
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")
}
}
-306
View File
@@ -1,306 +0,0 @@
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
}
-179
View File
@@ -1,179 +0,0 @@
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)
}
}
-27
View File
@@ -1,27 +0,0 @@
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
}
-12
View File
@@ -1,12 +0,0 @@
//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)
}
-9
View File
@@ -1,9 +0,0 @@
//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 }
-43
View File
@@ -1,43 +0,0 @@
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"`
}
+1 -4
View File
@@ -158,9 +158,6 @@ 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)
@@ -168,7 +165,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. 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", 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", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
}
-90
View File
@@ -86,96 +86,6 @@ 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()
+4 -57
View File
@@ -10,8 +10,6 @@ import (
"testing"
)
const minimalPDFTextChunkSize = 80
// buildMinimalPDF assembles a tiny, single-page PDF whose content stream draws
// the given text. It computes a real cross-reference table and trailer so a
// pure-Go PDF parser (ledongthuc/pdf) accepts it. Generating the fixture in-test
@@ -30,11 +28,10 @@ func buildMinimalPDF(text string) []byte {
startObj() // object 2: page tree
buf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
pageHeight := minimalPDFPageHeight(text)
startObj() // object 3: page
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 " + strconv.Itoa(pageHeight) + "] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
buf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n")
content := minimalPDFTextContent(text, pageHeight-92)
content := "BT /F1 24 Tf 72 700 Td (" + text + ") Tj ET"
startObj() // object 4: content stream
buf.WriteString("4 0 obj\n<< /Length " + strconv.Itoa(len(content)) + " >>\nstream\n")
buf.WriteString(content)
@@ -55,56 +52,6 @@ func buildMinimalPDF(text string) []byte {
return buf.Bytes()
}
func minimalPDFPageHeight(text string) int {
lines := (len(text) + minimalPDFTextChunkSize - 1) / minimalPDFTextChunkSize
if lines < 1 {
lines = 1
}
height := 184 + lines*10
if height < 792 {
return 792
}
return height
}
func minimalPDFTextContent(text string, startY int) string {
var content strings.Builder
content.WriteString("BT /F1 8 Tf 10 TL 72 ")
content.WriteString(strconv.Itoa(startY))
content.WriteString(" Td ")
for index := 0; len(text) > 0; index++ {
if index > 0 {
content.WriteString(" T* ")
}
chunk := text
if len(chunk) > minimalPDFTextChunkSize {
cut := minimalPDFTextChunkSize
for cut > 0 && !utf8RuneStart(chunk[cut]) {
cut--
}
if cut == 0 {
cut = minimalPDFTextChunkSize
}
chunk = text[:cut]
}
content.WriteString("(")
content.WriteString(escapePDFLiteral(chunk))
content.WriteString(") Tj")
text = text[len(chunk):]
}
content.WriteString(" ET")
return content.String()
}
func escapePDFLiteral(text string) string {
text = strings.ReplaceAll(text, `\`, `\\`)
text = strings.ReplaceAll(text, `(`, `\(`)
text = strings.ReplaceAll(text, `)`, `\)`)
text = strings.ReplaceAll(text, "\r", `\r`)
text = strings.ReplaceAll(text, "\n", `\n`)
return text
}
func TestIsPDF(t *testing.T) {
if !isPDF(buildMinimalPDF("hi")) {
t.Fatal("isPDF should accept real %PDF- bytes")
@@ -212,7 +159,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
// Many short lines so the *extracted text* (not the file) exceeds the cap.
var body strings.Builder
line := "The quick brown fox jumps over the lazy dog. "
for body.Len() < MaxDocumentTextBytes+65536 {
for body.Len() < MaxDocumentTextBytes+4096 {
body.WriteString(line)
}
if err := os.WriteFile(filepath.Join(root, "long.pdf"), buildMinimalPDF(body.String()), 0o644); err != nil {
@@ -226,7 +173,7 @@ func TestLoadDocumentTruncatesLongText(t *testing.T) {
t.Fatalf("capped text length %d exceeds cap %d (marker must be counted against the cap)", len(doc.Text), MaxDocumentTextBytes)
}
if !doc.Truncated {
t.Fatalf("Truncated should be set when extracted text is capped; extracted len=%d pages=%d", len(doc.Text), doc.Pages)
t.Fatal("Truncated should be set when extracted text is capped")
}
if !strings.Contains(doc.Text, documentTruncatedMarker) {
t.Fatal("truncated text should carry the truncation marker")
-225
View File
@@ -1,225 +0,0 @@
// Package keyring provides a small, dependency-free secret store backed by the
// operating system's native credential tooling: the `security` keychain CLI on
// macOS and `secret-tool` (libsecret) on Linux. It stores a single secret string
// per (service, account). Windows and other platforms report unsupported.
//
// It shells out to the OS tools rather than taking a third-party dependency. On
// macOS the secret is passed to `security` as an argument, so it is briefly
// visible to other processes via the process list; on Linux the secret is passed
// over stdin and is not exposed in the argument vector. Callers that need to keep
// a secret out of the process list on macOS should prefer the file backend.
package keyring
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
)
// commandTimeout bounds a single keyring tool invocation.
const commandTimeout = 10 * time.Second
// ErrUnsupported is returned when no OS keyring backend is available for the
// current platform.
var ErrUnsupported = errors.New("keyring: no OS keyring backend on this platform")
// runner executes name with args and optional stdin, returning stdout. It is the
// single seam tests replace to drive the platform command logic without touching
// a real keychain.
type runner func(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error)
// Keyring is an OS-native secret store.
type Keyring struct {
run runner
goos string
}
// New returns a Keyring for the current platform.
func New() *Keyring {
return &Keyring{run: execRunner, goos: runtime.GOOS}
}
// Available reports whether this platform has a supported keyring backend. The
// backing tool (`secret-tool` on Linux) must also be installed; a missing tool
// surfaces as an error from Get/Set/Delete.
func (k *Keyring) Available() bool {
switch k.goos {
case "darwin", "linux":
return true
default:
return false
}
}
// Set stores secret under (service, account), replacing any existing value.
func (k *Keyring) Set(service, account, secret string) error {
if err := validate(service, account); err != nil {
return err
}
switch k.goos {
case "darwin":
// -U updates the item if it already exists rather than failing.
_, err := k.exec(nil, "security", "add-generic-password", "-U", "-s", service, "-a", account, "-w", secret)
return wrap("set", err)
case "linux":
// secret-tool reads the secret from stdin, keeping it out of the argv.
_, err := k.exec([]byte(secret), "secret-tool", "store", "--label", "zero", "service", service, "account", account)
return wrap("set", err)
default:
return ErrUnsupported
}
}
// Get returns the secret stored under (service, account). The bool is false when
// no entry exists (which is not an error).
func (k *Keyring) Get(service, account string) (string, bool, error) {
if err := validate(service, account); err != nil {
return "", false, err
}
switch k.goos {
case "darwin":
out, err := k.exec(nil, "security", "find-generic-password", "-s", service, "-a", account, "-w")
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
return strings.TrimRight(string(out), "\r\n"), true, nil
case "linux":
out, err := k.exec(nil, "secret-tool", "lookup", "service", service, "account", account)
if err != nil {
if isNotFound(err, secretToolNotFoundExit) {
return "", false, nil
}
return "", false, wrap("get", err)
}
value := strings.TrimRight(string(out), "\r\n")
if value == "" {
return "", false, nil
}
return value, true, nil
default:
return "", false, ErrUnsupported
}
}
// Delete removes the entry under (service, account), reporting whether one
// existed.
func (k *Keyring) Delete(service, account string) (bool, error) {
if err := validate(service, account); err != nil {
return false, err
}
switch k.goos {
case "darwin":
_, err := k.exec(nil, "security", "delete-generic-password", "-s", service, "-a", account)
if err != nil {
if isNotFound(err, securityNotFoundExit) {
return false, nil
}
return false, wrap("delete", err)
}
return true, nil
case "linux":
// `secret-tool clear` always exits 0, so probe existence first.
_, existed, err := k.Get(service, account)
if err != nil {
return false, err
}
if _, err := k.exec(nil, "secret-tool", "clear", "service", service, "account", account); err != nil {
return false, wrap("delete", err)
}
return existed, nil
default:
return false, ErrUnsupported
}
}
func (k *Keyring) exec(stdin []byte, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
return k.run(ctx, name, stdin, args...)
}
func execRunner(ctx context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
if len(stdin) > 0 {
cmd.Stdin = bytes.NewReader(stdin)
}
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return out.Bytes(), &runError{err: err, stderr: strings.TrimSpace(errBuf.String())}
}
return out.Bytes(), nil
}
// runError carries a tool failure with its stderr and preserves the underlying
// error for errors.As (so exit-code / not-found detection still works).
type runError struct {
err error
stderr string
}
func (e *runError) Error() string {
if e.stderr != "" {
return e.stderr
}
return e.err.Error()
}
func (e *runError) Unwrap() error { return e.err }
// Not-found exit codes for the OS tools: macOS `security` exits 44
// (errSecItemNotFound) when no matching item exists; `secret-tool` exits 1 when a
// lookup finds nothing. Any other non-zero exit is a real failure.
const (
securityNotFoundExit = 44
secretToolNotFoundExit = 1
)
// isNotFound reports whether err is a tool exit whose code is one of the given
// "no such entry" codes (as opposed to a missing binary or a genuine failure,
// which must not be masked). It matches on the ExitCode behavior (satisfied by
// *exec.ExitError) so the logic is testable without spawning a real process.
func isNotFound(err error, codes ...int) bool {
var coder interface{ ExitCode() int }
if !errors.As(err, &coder) {
return false
}
code := coder.ExitCode()
for _, c := range codes {
if code == c {
return true
}
}
return false
}
// wrap adds operation context to a tool error, leaving nil untouched.
func wrap(op string, err error) error {
if err == nil {
return nil
}
var execErr *exec.Error
if errors.As(err, &execErr) {
return fmt.Errorf("keyring: %s: %q not found (install the OS keyring tool): %w", op, execErr.Name, err)
}
return fmt.Errorf("keyring: %s: %w", op, err)
}
func validate(service, account string) error {
if strings.TrimSpace(service) == "" {
return errors.New("keyring: service is required")
}
if strings.TrimSpace(account) == "" {
return errors.New("keyring: account is required")
}
return nil
}
-216
View File
@@ -1,216 +0,0 @@
package keyring
import (
"context"
"os/exec"
"strings"
"testing"
)
// fakeExit is a not-found exit error: it satisfies the ExitCode() seam used by
// isNotFound without spawning a real process.
type fakeExit struct{ code int }
func (e fakeExit) Error() string { return "exit status" }
func (e fakeExit) ExitCode() int { return e.code }
// fakeKeyring is an in-memory simulation of the OS tools driven through the
// runner seam. It records the last stdin so tests can assert the secret never
// travels via argv on Linux.
type fakeKeyring struct {
goos string
data map[string]string
lastStdin string
lastArgs []string
}
func newFake(goos string) *fakeKeyring {
return &fakeKeyring{goos: goos, data: map[string]string{}}
}
func (f *fakeKeyring) keyring() *Keyring { return &Keyring{run: f.run, goos: f.goos} }
func flagValue(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func attrValue(args []string, attr string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == attr {
return args[i+1]
}
}
return ""
}
func key(service, account string) string { return service + "\x00" + account }
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
f.lastStdin = string(stdin)
f.lastArgs = append([]string{name}, args...)
if len(args) == 0 {
return nil, fakeExit{1}
}
switch f.goos {
case "darwin":
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
switch args[0] {
case "add-generic-password":
f.data[key(svc, acct)] = flagValue(args, "-w")
return nil, nil
case "find-generic-password":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v + "\n"), nil // security prints a trailing newline
}
return nil, fakeExit{44}
case "delete-generic-password":
if _, ok := f.data[key(svc, acct)]; ok {
delete(f.data, key(svc, acct))
return nil, nil
}
return nil, fakeExit{44}
}
case "linux":
svc, acct := attrValue(args, "service"), attrValue(args, "account")
switch args[0] {
case "store":
f.data[key(svc, acct)] = string(stdin)
return nil, nil
case "lookup":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v), nil // secret-tool prints no trailing newline
}
return nil, fakeExit{1}
case "clear":
delete(f.data, key(svc, acct))
return nil, nil
}
}
return nil, fakeExit{1}
}
func TestKeyringGetSurfacesNonNotFoundError(t *testing.T) {
// On macOS only exit 44 (errSecItemNotFound) means "no entry"; any other
// non-zero exit is a real failure that must surface, not be masked as absent.
k := &Keyring{
goos: "darwin",
run: func(_ context.Context, _ string, _ []byte, _ ...string) ([]byte, error) {
return nil, fakeExit{1}
},
}
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("a non-44 exit must surface as an error, got ok=%v err=%v", ok, err)
}
}
func TestKeyringRoundTripDarwin(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("zero", "tokens", "blob-AAA"); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
if got != "blob-AAA" {
t.Fatalf("Get = %q, want blob-AAA", got)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
if _, ok, _ := k.Get("zero", "tokens"); ok {
t.Fatal("token should be gone after delete")
}
}
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
f := newFake("linux")
k := f.keyring()
if err := k.Set("zero", "tokens", "blob-BBB"); err != nil {
t.Fatalf("Set: %v", err)
}
// The secret must travel via stdin, never the argument vector.
if f.lastStdin != "blob-BBB" {
t.Fatalf("secret not sent via stdin: stdin=%q", f.lastStdin)
}
for _, a := range f.lastArgs {
if strings.Contains(a, "blob-BBB") {
t.Fatalf("secret leaked into argv: %v", f.lastArgs)
}
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != "blob-BBB" {
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
}
func TestKeyringGetMissingIsNotError(t *testing.T) {
for _, goos := range []string{"darwin", "linux"} {
k := newFake(goos).keyring()
if _, ok, err := k.Get("zero", "absent"); err != nil || ok {
t.Fatalf("[%s] Get(absent) = ok=%v err=%v, want false/nil", goos, ok, err)
}
if existed, err := k.Delete("zero", "absent"); err != nil || existed {
t.Fatalf("[%s] Delete(absent) = existed=%v err=%v, want false/nil", goos, existed, err)
}
}
}
func TestKeyringUnsupportedPlatform(t *testing.T) {
k := &Keyring{run: newFake("windows").run, goos: "windows"}
if k.Available() {
t.Fatal("windows should report unavailable")
}
if err := k.Set("zero", "tokens", "x"); err == nil {
t.Fatal("Set on unsupported platform should error")
}
if _, _, err := k.Get("zero", "tokens"); err == nil {
t.Fatal("Get on unsupported platform should error")
}
if _, err := k.Delete("zero", "tokens"); err == nil {
t.Fatal("Delete on unsupported platform should error")
}
}
func TestKeyringValidation(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("", "a", "s"); err == nil {
t.Fatal("empty service should error")
}
if err := k.Set("svc", "", "s"); err == nil {
t.Fatal("empty account should error")
}
}
func TestKeyringMissingBinaryError(t *testing.T) {
// A missing tool surfaces as a wrapped, descriptive error (not not-found).
k := &Keyring{goos: "linux", run: func(context.Context, string, []byte, ...string) ([]byte, error) {
return nil, &exec.Error{Name: "secret-tool", Err: exec.ErrNotFound}
}}
if err := k.Set("zero", "tokens", "x"); err == nil || !strings.Contains(err.Error(), "secret-tool") {
t.Fatalf("missing-binary Set error = %v, want mention of secret-tool", err)
}
// A missing binary on Get must not be misread as not-found.
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("missing-binary Get = ok=%v err=%v, want error", ok, err)
}
}
func TestAvailable(t *testing.T) {
if !(newFake("darwin").keyring().Available()) {
t.Fatal("darwin should be available")
}
if !(newFake("linux").keyring().Available()) {
t.Fatal("linux should be available")
}
}
+187 -79
View File
@@ -2,6 +2,9 @@ package mcp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -11,56 +14,20 @@ 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 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,
}
}
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
)
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
// Endpoints may be discovered from the server's metadata document; explicit
@@ -117,20 +84,34 @@ type authorizationFlow struct {
now func() time.Time
}
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
// at the well-known path under baseURL, via the shared engine.
// discoverAuthorizationServer fetches the OAuth 2.0 authorization server
// metadata document published at the well-known path under the given base URL.
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
if client == nil {
client = http.DefaultClient
}
metadataURL, err := joinWellKnown(baseURL)
if err != nil {
return authServerMetadata{}, err
}
return authServerMetadata{
Issuer: meta.Issuer,
AuthorizationEndpoint: meta.AuthorizationEndpoint,
TokenEndpoint: meta.TokenEndpoint,
RegistrationEndpoint: meta.RegistrationEndpoint,
ScopesSupported: meta.ScopesSupported,
}, nil
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
}
// resolveAuthorizationServer discovers metadata and applies explicit config
@@ -168,23 +149,44 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
return metadata, nil
}
// newPKCE generates a high-entropy code verifier and its S256 challenge via the
// shared engine.
// newPKCE generates a high-entropy code verifier and its S256 challenge.
func newPKCE() (pkceParams, error) {
p, err := oauth.NewPKCE()
if err != nil {
return pkceParams{}, err
verifierRaw := make([]byte, pkceVerifierBytes)
if _, err := rand.Read(verifierRaw); err != nil {
return pkceParams{}, fmt.Errorf("generate PKCE verifier: %w", err)
}
return pkceToParams(p), nil
verifier := base64.RawURLEncoding.EncodeToString(verifierRaw)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
return pkceParams{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
}
func newState() (string, error) {
return oauth.NewState()
raw := make([]byte, stateBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// authorizationURL builds the authorization request URL via the shared engine.
// authorizationURL builds the authorization request URL.
func (flow *authorizationFlow) authorizationURL(redirectURI string) (string, error) {
return oauth.BuildAuthorizationURL(configFor(flow.config, flow.metadata), pkceToOAuth(flow.pkce), flow.state, redirectURI, nil)
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
}
// parseCallback validates the redirect query and returns the authorization
@@ -207,30 +209,117 @@ func (flow *authorizationFlow) parseCallback(values url.Values) (string, error)
return code, nil
}
// exchangeCode swaps an authorization code + PKCE verifier for tokens via the
// shared engine.
// exchangeCode swaps an authorization code + PKCE verifier for tokens.
func (flow *authorizationFlow) exchangeCode(ctx context.Context, code string, redirectURI string) (StoredToken, error) {
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
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)
}
return tokenToStored(token), nil
return postTokenRequest(ctx, flow.httpClient, flow.metadata.TokenEndpoint, form, StoredToken{Scopes: flow.config.Scopes}, flow.now)
}
// 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.
// refreshAccessToken exchanges a refresh token for a fresh access token. 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) {
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)
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)
if err != nil {
return StoredToken{}, err
}
return tokenToStored(token), nil
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
}
// registerClient performs dynamic client registration against the registration
@@ -416,3 +505,22 @@ 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
}
+170 -129
View File
@@ -6,15 +6,17 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
// tokenStoreSchemaVersion is the schema of the legacy mcp-oauth-tokens.json file,
// retained so migration can recognize a file it understands.
const tokenStoreSchemaVersion = 1
const (
tokenStoreSchemaVersion = 1
tokenStoreLockTimeout = 5 * time.Second
tokenStoreLockRetry = 10 * time.Millisecond
)
// StoredToken holds the credentials issued by an OAuth 2.0 authorization server
// for a single MCP server. The token fields are sensitive: they are tagged so
@@ -40,37 +42,29 @@ type TokenStatus struct {
Expired bool `json:"expired"`
}
// TokenStoreOptions configures the unified token store backing MCP OAuth tokens.
// FilePath overrides the store path (default: the shared oauth store path).
// LegacyPath overrides the pre-unification file migrated on construction; when
// empty it defaults to the conventional mcp-oauth-tokens.json only for the
// default (FilePath-unset) store, so an explicit FilePath never triggers an
// unexpected migration from the real user config.
// TokenStoreOptions configures where OAuth tokens are persisted. When FilePath
// is empty the path is resolved under the user config dir, mirroring how MCP
// permissions are stored.
type TokenStoreOptions struct {
FilePath string
LegacyPath string
Env map[string]string
Now func() time.Time
FilePath string
Env map[string]string
Now func() time.Time
}
// TokenStore persists MCP OAuth tokens in the unified oauth store
// (internal/oauth) under the "mcp:" namespace, sharing one file with provider
// logins. On construction it transparently and non-destructively migrates a
// legacy mcp-oauth-tokens.json into the unified store.
// TokenStore persists OAuth tokens per MCP server in a 0600 JSON file.
type TokenStore struct {
store *oauth.Store
filePath string
now func() time.Time
mu sync.Mutex
}
// tokenFile is the legacy on-disk format, retained only to read a
// pre-unification mcp-oauth-tokens.json during migration.
type tokenFile struct {
SchemaVersion int `json:"schemaVersion"`
Tokens map[string]StoredToken `json:"tokens"`
}
// ResolveTokenStorePath determines the on-disk location of the LEGACY OAuth token
// file, honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
// It is used to locate a pre-unification file for migration.
// ResolveTokenStorePath determines the on-disk location for OAuth tokens,
// honoring an explicit override, XDG_CONFIG_HOME, then the user home dir.
func ResolveTokenStorePath(env map[string]string) (string, error) {
override := strings.TrimSpace(envValue(env, "ZERO_MCP_OAUTH_TOKENS_PATH"))
if override != "" {
@@ -101,168 +95,215 @@ func ResolveTokenStorePath(env map[string]string) (string, error) {
return filepath.Join(configHome, "zero", "mcp-oauth-tokens.json"), nil
}
// NewTokenStore builds the unified-store-backed token store and runs a one-time
// migration from a legacy file when applicable.
// NewTokenStore builds a file-backed token store.
func NewTokenStore(options TokenStoreOptions) (*TokenStore, error) {
now := options.Now
if now == nil {
now = time.Now
}
unified, err := oauth.NewStore(oauth.StoreOptions{
FilePath: options.FilePath,
Env: options.Env,
Now: now,
})
if err != nil {
return nil, err
}
store := &TokenStore{store: unified}
legacyPath := strings.TrimSpace(options.LegacyPath)
if legacyPath == "" && strings.TrimSpace(options.FilePath) == "" {
// Default (production) construction: migrate from the conventional path.
legacyPath, err = ResolveTokenStorePath(options.Env)
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveTokenStorePath(options.Env)
if err != nil {
return nil, err
}
}
if legacyPath != "" {
if err := store.migrateLegacy(legacyPath); err != nil {
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return nil, err
}
}
return store, nil
now := options.Now
if now == nil {
now = time.Now
}
return &TokenStore{filePath: filepath.Clean(filePath), now: now}, nil
}
// FilePath returns the resolved unified store path.
// FilePath returns the resolved token store path.
func (store *TokenStore) FilePath() string {
return store.store.FilePath()
return store.filePath
}
// Save persists the token for a server, replacing any existing entry.
func (store *TokenStore) Save(serverName string, token StoredToken) error {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
if err != nil {
return err
}
return store.store.Save(key, storedToOAuth(token))
defer unlock()
state, err := store.readState()
if err != nil {
return err
}
state.Tokens[serverName] = token
return store.writeState(state)
}
// Load returns the stored token for a server. The second return value is false
// when no token has been stored for the server.
func (store *TokenStore) Load(serverName string) (StoredToken, bool, error) {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return StoredToken{}, false, err
}
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return StoredToken{}, false, err
}
token, ok, err := store.store.Load(key)
if err != nil || !ok {
return StoredToken{}, ok, err
}
return tokenToStored(token), true, nil
token, ok := state.Tokens[serverName]
return token, ok, nil
}
// Delete removes the stored token for a server. It reports whether an entry was
// present before deletion.
func (store *TokenStore) Delete(serverName string) (bool, error) {
key, err := mcpKey(serverName)
if err := ValidateServerName(serverName); err != nil {
return false, err
}
store.mu.Lock()
defer store.mu.Unlock()
unlock, err := store.lockStateFile()
if err != nil {
return false, err
}
return store.store.Delete(key)
defer unlock()
state, err := store.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[serverName]; !ok {
return false, nil
}
delete(state.Tokens, serverName)
if err := store.writeState(state); err != nil {
return false, err
}
return true, nil
}
// Status returns a redaction-safe summary of every stored MCP token, sorted by
// Status returns a redaction-safe summary of every stored token, sorted by
// server name. It never includes the token material.
func (store *TokenStore) Status() ([]TokenStatus, error) {
statuses, err := store.store.Status(oauth.KeyPrefixMCP)
store.mu.Lock()
defer store.mu.Unlock()
state, err := store.readState()
if err != nil {
return nil, err
}
out := make([]TokenStatus, 0, len(statuses))
for _, s := range statuses {
out = append(out, TokenStatus{
ServerName: strings.TrimPrefix(s.Key, oauth.KeyPrefixMCP),
HasToken: s.HasToken,
HasRefreshToken: s.HasRefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
Expired: s.Expired,
})
names := make([]string, 0, len(state.Tokens))
for name := range state.Tokens {
names = append(names, name)
}
return out, nil
sort.Strings(names)
now := store.now()
statuses := make([]TokenStatus, 0, len(names))
for _, name := range names {
token := state.Tokens[name]
status := TokenStatus{
ServerName: name,
HasToken: strings.TrimSpace(token.AccessToken) != "",
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
TokenType: token.TokenType,
Scopes: token.Scopes,
ExpiresAt: token.ExpiresAt,
}
if !token.ExpiresAt.IsZero() && !token.ExpiresAt.After(now) {
status.Expired = true
}
statuses = append(statuses, status)
}
return statuses, nil
}
// migrateLegacy imports tokens from a legacy mcp-oauth-tokens.json into the
// unified store (under "mcp:" keys), then renames the legacy file to a
// ".migrated" backup. It is non-destructive and idempotent: a newer unified
// entry is never overwritten, a missing/unreadable/foreign-schema legacy file is
// left untouched, and the rename ensures it is imported at most once.
func (store *TokenStore) migrateLegacy(legacyPath string) error {
legacyPath = filepath.Clean(legacyPath)
// FilePath() is an absolute path; resolve a relative LegacyPath so the
// same-file guard can't be bypassed by a relative spelling of the same file.
if !filepath.IsAbs(legacyPath) {
abs, err := filepath.Abs(legacyPath)
if err != nil {
return err
}
legacyPath = filepath.Clean(abs)
}
if legacyPath == store.store.FilePath() {
return nil // legacy and unified resolve to the same file; nothing to migrate
}
data, err := os.ReadFile(legacyPath)
func (store *TokenStore) readState() (tokenFile, error) {
data, err := os.ReadFile(store.filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
return emptyTokenFile(), nil
}
return tokenFile{}, err
}
var state tokenFile
if err := json.Unmarshal(data, &state); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
if state.SchemaVersion != tokenStoreSchemaVersion {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: unsupported schemaVersion", store.filePath)
}
if state.Tokens == nil {
state.Tokens = map[string]StoredToken{}
}
for serverName := range state.Tokens {
if err := ValidateServerName(serverName); err != nil {
return tokenFile{}, fmt.Errorf("invalid MCP OAuth token file at %s: %w", store.filePath, err)
}
}
return state, nil
}
func (store *TokenStore) writeState(state tokenFile) error {
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return err
}
var legacy tokenFile
if err := json.Unmarshal(data, &legacy); err != nil {
return nil // unreadable legacy file: leave it in place, don't block startup
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if legacy.SchemaVersion != tokenStoreSchemaVersion {
return nil // unknown legacy schema: leave it untouched
tempPath := fmt.Sprintf("%s.tmp-%d-%d", store.filePath, os.Getpid(), store.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
}
for serverName, token := range legacy.Tokens {
key, err := mcpKey(serverName)
if err := os.Rename(tempPath, store.filePath); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func (store *TokenStore) lockStateFile() (func(), error) {
lockPath := store.filePath + ".lockfile"
if err := os.MkdirAll(filepath.Dir(store.filePath), 0o700); err != nil {
return nil, err
}
file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
deadline := time.Now().Add(tokenStoreLockTimeout)
for {
locked, err := tryLockPermissionFile(file)
if err != nil {
continue // a name that cannot form a valid unified key is skipped
_ = file.Close()
return nil, err
}
if _, ok, loadErr := store.store.Load(key); loadErr == nil && ok {
continue // a unified entry already exists; never overwrite
if locked {
return func() {
_ = unlockPermissionFile(file)
_ = file.Close()
}, nil
}
if err := store.store.Save(key, storedToOAuth(token)); err != nil {
return err
if time.Now().After(deadline) {
_ = file.Close()
return nil, fmt.Errorf("timed out waiting for MCP OAuth token lock at %s", lockPath)
}
time.Sleep(tokenStoreLockRetry)
}
return os.Rename(legacyPath, legacyPath+".migrated")
}
// mcpKey builds and validates the unified store key for an MCP server token.
func mcpKey(serverName string) (string, error) {
if err := ValidateServerName(serverName); err != nil {
return "", err
}
key := oauth.KeyPrefixMCP + strings.TrimSpace(serverName)
if err := oauth.ValidateKey(key); err != nil {
return "", err
}
return key, nil
}
// storedToOAuth converts an MCP StoredToken to the shared oauth.Token (the
// inverse of tokenToStored). MCP never sets the oauth Account field.
func storedToOAuth(s StoredToken) oauth.Token {
return oauth.Token{
AccessToken: s.AccessToken,
RefreshToken: s.RefreshToken,
TokenType: s.TokenType,
Scopes: s.Scopes,
ExpiresAt: s.ExpiresAt,
func emptyTokenFile() tokenFile {
return tokenFile{
SchemaVersion: tokenStoreSchemaVersion,
Tokens: map[string]StoredToken{},
}
}
-95
View File
@@ -158,101 +158,6 @@ func TestTokenStoreStatusReportsPresenceWithoutToken(t *testing.T) {
}
}
func TestTokenStoreMigratesLegacyFile(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
legacyData := `{"schemaVersion":1,"tokens":{"demo":{"access_token":"a","refresh_token":"r","token_type":"Bearer"}}}`
if err := os.WriteFile(legacy, []byte(legacyData), 0o600); err != nil {
t.Fatal(err)
}
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore: %v", err)
}
tok, ok, err := store.Load("demo")
if err != nil || !ok {
t.Fatalf("migrated token not loadable: ok=%v err=%v", ok, err)
}
if tok.AccessToken != "a" || tok.RefreshToken != "r" {
t.Fatalf("migrated token = %#v", tok)
}
// The unified file keys the token under the mcp: namespace.
raw, err := os.ReadFile(unified)
if err != nil {
t.Fatalf("read unified: %v", err)
}
if !contains(string(raw), "mcp:demo") {
t.Fatalf("unified file should key under mcp: namespace:\n%s", raw)
}
// The legacy file is renamed to a .migrated backup (non-destructive, one-time).
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Fatalf("legacy file should be renamed away; stat err = %v", err)
}
if _, err := os.Stat(legacy + ".migrated"); err != nil {
t.Fatalf("legacy backup missing: %v", err)
}
// Idempotent: a second construction (legacy now absent) keeps the token.
store2, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatalf("NewTokenStore#2: %v", err)
}
if _, ok, _ := store2.Load("demo"); !ok {
t.Fatal("token lost after second construction")
}
}
func TestTokenStoreMigrationPreservesNewerUnified(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "mcp-oauth-tokens.json")
unified := filepath.Join(dir, "oauth-tokens.json")
if err := os.WriteFile(legacy, []byte(`{"schemaVersion":1,"tokens":{"demo":{"access_token":"OLD"}}}`), 0o600); err != nil {
t.Fatal(err)
}
// Pre-seed the unified store with a newer token (no migration: FilePath set, no LegacyPath).
pre, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := pre.Save("demo", StoredToken{AccessToken: "NEW"}); err != nil {
t.Fatal(err)
}
// Migrating must not overwrite the newer unified entry.
store, err := NewTokenStore(TokenStoreOptions{FilePath: unified, LegacyPath: legacy})
if err != nil {
t.Fatal(err)
}
tok, _, _ := store.Load("demo")
if tok.AccessToken != "NEW" {
t.Fatalf("migration overwrote a newer token: %q", tok.AccessToken)
}
}
func TestTokenStoreNamespacedFromProvider(t *testing.T) {
// An MCP token and a provider login of the same name coexist in one file.
dir := t.TempDir()
unified := filepath.Join(dir, "oauth-tokens.json")
mcpStore, err := NewTokenStore(TokenStoreOptions{FilePath: unified})
if err != nil {
t.Fatal(err)
}
if err := mcpStore.Save("shared", StoredToken{AccessToken: "mcp-token"}); err != nil {
t.Fatal(err)
}
statuses, err := mcpStore.Status()
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].ServerName != "shared" {
t.Fatalf("status = %#v, want one entry for 'shared'", statuses)
}
}
func TestResolveTokenStorePathUsesXDG(t *testing.T) {
// Use a real temp dir so the base is absolute on every OS (a literal
// "/tmp/..." isn't absolute on Windows, where ResolveTokenStorePath would
-40
View File
@@ -1,40 +0,0 @@
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,
}))
}
-71
View File
@@ -1,71 +0,0 @@
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)
}
}
-213
View File
@@ -1,213 +0,0 @@
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)
}
}
-143
View File
@@ -1,143 +0,0 @@
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)
}
}
-79
View File
@@ -1,79 +0,0 @@
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
}
-147
View File
@@ -1,147 +0,0 @@
package oauth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// secretBytes is the AES-256 key length kept in the per-user secret file.
const secretBytes = 32
// aesGCMCrypter encrypts the token file at rest with AES-256-GCM under a
// per-user random secret persisted (0600) beside the token file. The on-disk
// blob is nonce || ciphertext; GCM provides confidentiality AND tamper
// detection, so a corrupted/forged file fails closed on open. This is the
// opt-in "encrypted-file" storage backend; the default backend writes the
// 0600 plaintext JSON unchanged.
type aesGCMCrypter struct {
secretPath string
}
func newAESGCMCrypter(secretPath string) *aesGCMCrypter {
return &aesGCMCrypter{secretPath: secretPath}
}
// aead loads (or, when create is set, generates) the secret and returns the GCM
// AEAD. open passes create=false so a missing secret is a hard error rather than
// silently minting a new key that could never decrypt the existing file.
func (c *aesGCMCrypter) aead(create bool) (cipher.AEAD, error) {
secret, err := loadOrCreateSecret(c.secretPath, create)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(secret)
if err != nil {
return nil, fmt.Errorf("oauth: build cipher: %w", err)
}
return cipher.NewGCM(block)
}
// seal encrypts plaintext, prefixing a fresh random nonce. It creates the secret
// on first use.
func (c *aesGCMCrypter) seal(plaintext []byte) ([]byte, error) {
gcm, err := c.aead(true)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("oauth: generate nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// open decrypts a nonce||ciphertext blob, failing closed on a missing secret,
// a short blob, or a failed authentication tag (tampering / wrong key).
func (c *aesGCMCrypter) open(blob []byte) ([]byte, error) {
gcm, err := c.aead(false)
if err != nil {
return nil, err
}
if len(blob) < gcm.NonceSize() {
return nil, errors.New("oauth: encrypted token file is too short")
}
nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("oauth: decrypt token file (wrong secret or tampered): %w", err)
}
return plaintext, nil
}
// loadOrCreateSecret reads the 32-byte secret at path. When create is set and
// the file is absent, it generates a random secret and creates the file
// exclusively (0600). A wrong-sized existing secret fails closed (corruption).
func loadOrCreateSecret(path string, create bool) ([]byte, error) {
if data, err := readSecretFile(path); err == nil {
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if !create {
return nil, fmt.Errorf("oauth: token secret %s is missing; cannot decrypt the token file", path)
}
secret := make([]byte, secretBytes)
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
return nil, fmt.Errorf("oauth: generate token secret: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
// Create exclusively: an os.Rename publish would clobber on POSIX, so two
// concurrent first-run processes could each generate a secret and the loser
// would silently orphan the tokens it encrypts. O_EXCL lets exactly one
// process win; everyone else adopts the winner's on-disk secret.
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
if errors.Is(err, os.ErrExist) {
// The winner may not have finished writing yet, so a read here can see a
// short/absent file. Retry briefly so concurrent first-run invocations
// converge on the winner's secret instead of failing transiently.
var lastErr error
for attempt := 0; attempt < 50; attempt++ {
secret, rerr := readSecretFile(path)
if rerr == nil {
return secret, nil
}
lastErr = rerr
time.Sleep(2 * time.Millisecond)
}
return nil, lastErr
}
return nil, fmt.Errorf("oauth: create token secret: %w", err)
}
if _, werr := f.Write(secret); werr != nil {
_ = f.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("oauth: write token secret: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("oauth: write token secret: %w", cerr)
}
return secret, nil
}
// readSecretFile reads and validates the secret at path, returning a wrapped
// os.ErrNotExist when it is absent so callers can branch on creation.
func readSecretFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, err
}
return nil, fmt.Errorf("oauth: read token secret: %w", err)
}
if len(data) != secretBytes {
return nil, fmt.Errorf("oauth: token secret at %s has unexpected size %d", path, len(data))
}
return data, nil
}
-204
View File
@@ -1,204 +0,0 @@
package oauth
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
func TestLoadOrCreateSecretConcurrentConverges(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
const n = 16
secrets := make([][]byte, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
secrets[i], errs[i] = loadOrCreateSecret(path, true)
}(i)
}
wg.Wait()
// Exactly one creator wins; every racer must converge on the same on-disk
// secret rather than orphaning its own (the O_EXCL + bounded-retry path).
for i := 0; i < n; i++ {
if errs[i] != nil {
t.Fatalf("goroutine %d: %v", i, errs[i])
}
if !bytes.Equal(secrets[i], secrets[0]) {
t.Fatalf("goroutine %d got a divergent secret; concurrent create did not converge", i)
}
}
}
func TestAESGCMCrypterRoundTripAndTamper(t *testing.T) {
secretPath := filepath.Join(t.TempDir(), "tok.json.secret")
c := newAESGCMCrypter(secretPath)
plaintext := []byte(`{"schemaVersion":1,"tokens":{}}`)
blob, err := c.seal(plaintext)
if err != nil {
t.Fatalf("seal: %v", err)
}
if bytes.Contains(blob, plaintext) {
t.Fatal("sealed blob must not contain the plaintext")
}
got, err := c.open(blob)
if err != nil {
t.Fatalf("open: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("round-trip mismatch: %q", got)
}
// Tamper: flipping any byte breaks the GCM tag.
tampered := append([]byte(nil), blob...)
tampered[len(tampered)-1] ^= 0xff
if _, err := c.open(tampered); err == nil {
t.Fatal("open must reject a tampered blob (GCM auth)")
}
}
func TestLoadOrCreateSecret(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json.secret")
// Missing + create=false => fail closed (can't decrypt without the secret).
if _, err := loadOrCreateSecret(path, false); err == nil {
t.Fatal("missing secret with create=false must error")
}
secret, err := loadOrCreateSecret(path, true)
if err != nil {
t.Fatalf("create: %v", err)
}
if len(secret) != secretBytes {
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
}
// Stable across reads.
again, err := loadOrCreateSecret(path, false)
if err != nil || !bytes.Equal(again, secret) {
t.Fatalf("secret not stable: %v", err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat secret: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("secret file mode = %o, want 600", perm)
}
}
// Wrong-sized secret fails closed.
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatalf("corrupt secret: %v", err)
}
if _, err := loadOrCreateSecret(path, true); err == nil {
t.Fatal("wrong-sized secret must error")
}
}
func newEncryptedStore(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore(encrypted): %v", err)
}
return s, path
}
// The unified Storage="encrypted-file" selector must encrypt at rest, the same as
// the legacy Encrypted:true alias (the file/keyring/encrypted-file merge).
func TestNewStoreEncryptedFileStorageSelector(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path, Storage: "encrypted-file"})
if err != nil {
t.Fatalf("NewStore(encrypted-file): %v", err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "super-secret-access"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("Storage=encrypted-file did not encrypt at rest:\n%s", raw)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
}
func TestEncryptedStoreRoundTripAndCiphertextOnDisk(t *testing.T) {
s, path := newEncryptedStore(t)
tok := Token{AccessToken: "super-secret-access", RefreshToken: "super-secret-refresh", Account: "me@x"}
if err := s.Save(ProviderKey("demo"), tok); err != nil {
t.Fatalf("Save: %v", err)
}
// On-disk file must be ciphertext: not valid JSON, no plaintext token.
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if strings.Contains(string(raw), "super-secret-access") || strings.Contains(string(raw), "schemaVersion") {
t.Fatalf("token file is not encrypted at rest:\n%s", raw)
}
// The secret file exists beside it.
if _, err := os.Stat(path + ".secret"); err != nil {
t.Fatalf("secret file missing: %v", err)
}
// A fresh Store (same path) decrypts and reads it back.
s2, err := NewStore(StoreOptions{FilePath: path, Encrypted: true})
if err != nil {
t.Fatalf("NewStore 2: %v", err)
}
got, ok, err := s2.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "super-secret-access" || got.Account != "me@x" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
// Delete + Status work through the encrypted backend too.
statuses, err := s2.Status(KeyPrefixProvider)
if err != nil || len(statuses) != 1 {
t.Fatalf("Status = %+v err=%v", statuses, err)
}
if removed, err := s2.Delete(ProviderKey("demo")); err != nil || !removed {
t.Fatalf("Delete = %v %v", removed, err)
}
}
func TestEncryptedStoreTamperFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read file: %v", err)
}
if len(raw) == 0 {
t.Fatal("encrypted token file is empty")
}
raw[len(raw)-1] ^= 0xff
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("rewrite: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("a tampered encrypted store must fail closed")
}
}
func TestEncryptedStoreMissingSecretFailsClosed(t *testing.T) {
s, path := newEncryptedStore(t)
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
if err := os.Remove(path + ".secret"); err != nil {
t.Fatalf("remove secret: %v", err)
}
if _, _, err := s.Load(ProviderKey("demo")); err == nil {
t.Fatal("missing secret must fail closed, not return empty")
}
}
-212
View File
@@ -1,212 +0,0 @@
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
}
-212
View File
@@ -1,212 +0,0 @@
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)
}
}
-74
View File
@@ -1,74 +0,0 @@
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)
}
}
-112
View File
@@ -1,112 +0,0 @@
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
}
-86
View File
@@ -1,86 +0,0 @@
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)
}
}
-380
View File
@@ -1,380 +0,0 @@
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
}
-252
View File
@@ -1,252 +0,0 @@
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")
}
}
-97
View File
@@ -1,97 +0,0 @@
// 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) }
-49
View File
@@ -1,49 +0,0 @@
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
}
-74
View File
@@ -1,74 +0,0 @@
package oauth
import "strings"
// providerPreset is a baked-in default OAuth configuration for a well-known
// provider. Every field is overridable per provider via ZERO_OAUTH_<NAME>_*
// env vars (env wins). Only providers whose OAuth flow is verified to yield a
// credential usable for model calls are listed here.
type providerPreset struct {
ClientID string
ClientSecret string
AuthorizationEndpoint string
TokenEndpoint string
DeviceAuthorizationEndpoint string
IssuerURL string
Scopes []string
Flow Flow
}
// builtinOAuthPresets maps a provider name to its default OAuth config.
//
// These presets are OFF by default and only consulted when the operator opts in
// with ZERO_OAUTH_ALLOW_PRESETS (see presetsAllowed). A preset carries a
// third-party OAuth client identity, and the engine keeps such identities out of
// the default credential path (see the package doc) — opting in is an explicit
// acknowledgement that the binary's preset client_id will be used when no
// ZERO_OAUTH_<NAME>_* override is set.
//
// xAI (Grok): the client_id is a PUBLIC client (no secret) used by several Grok
// CLIs; its access token is accepted directly as a bearer on api.x.ai/v1 (an
// OpenAI-compatible endpoint), so no header/identity spoofing is involved.
// CAVEATS: it is NOT formally documented by xAI as a public developer API and may
// change without notice (override via ZERO_OAUTH_XAI_*), and using it requires a
// SuperGrok / X Premium+ subscription. Pay-as-you-go users should use a console
// API key instead.
var builtinOAuthPresets = map[string]providerPreset{
"xai": {
ClientID: "b1a00492-073a-47ea-816f-4c329264a828",
AuthorizationEndpoint: "https://auth.x.ai/oauth2/authorize",
TokenEndpoint: "https://auth.x.ai/oauth2/token",
DeviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
IssuerURL: "https://auth.x.ai",
Scopes: []string{"openid", "profile", "email", "offline_access", "grok-cli:access", "api:access"},
Flow: FlowLoopback,
},
}
// lookupOAuthPreset returns the baked-in preset for a provider name (if any).
func lookupOAuthPreset(name string) (providerPreset, bool) {
preset, ok := builtinOAuthPresets[strings.ToLower(strings.TrimSpace(name))]
return preset, ok
}
// presetsAllowed reports whether baked-in OAuth presets may supply defaults. They
// are OFF unless the operator opts in with ZERO_OAUTH_ALLOW_PRESETS set to a
// truthy value, keeping any third-party OAuth client identity out of the default
// credential path (a preset client_id is only ever used after explicit opt-in).
func presetsAllowed(env map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(envValue(env, "ZERO_OAUTH_ALLOW_PRESETS"))) {
case "1", "true", "yes", "on":
return true
}
return false
}
// scopesOrPreset returns the env scopes (space-separated) when set, else the
// preset's scopes.
func scopesOrPreset(envScopes string, preset []string) []string {
if fields := strings.Fields(envScopes); len(fields) > 0 {
return fields
}
// Copy so a caller appending to cfg.Scopes can't mutate the shared preset slice.
return append([]string(nil), preset...)
}
-95
View File
@@ -1,95 +0,0 @@
package oauth
import (
"strings"
"testing"
)
func TestScopesOrPresetReturnsACopy(t *testing.T) {
preset := []string{"openid", "profile"}
got := scopesOrPreset("", preset)
if len(got) != 2 || got[0] != "openid" {
t.Fatalf("got %v, want the preset scopes", got)
}
// Mutating the result must not bleed into the shared preset slice.
got[0] = "MUTATED"
if preset[0] != "openid" {
t.Fatalf("scopesOrPreset aliased the shared preset slice: %v", preset)
}
}
func TestResolveConfigUsesXAIPreset(t *testing.T) {
r := NewRegistry()
// Presets are opt-in; with ZERO_OAUTH_ALLOW_PRESETS set and no other env vars
// the preset supplies everything.
cfg, flow, err := r.ResolveConfig("xai", map[string]string{"ZERO_OAUTH_ALLOW_PRESETS": "1"})
if err != nil {
t.Fatalf("ResolveConfig(xai): %v", err)
}
if cfg.ClientID != "b1a00492-073a-47ea-816f-4c329264a828" {
t.Fatalf("client_id = %q", cfg.ClientID)
}
if cfg.AuthorizationEndpoint != "https://auth.x.ai/oauth2/authorize" {
t.Fatalf("authorize = %q", cfg.AuthorizationEndpoint)
}
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("token = %q", cfg.TokenEndpoint)
}
if cfg.DeviceAuthorizationEndpoint != "https://auth.x.ai/oauth2/device/code" {
t.Fatalf("device = %q", cfg.DeviceAuthorizationEndpoint)
}
if flow != FlowLoopback {
t.Fatalf("flow = %q, want loopback", flow)
}
if len(cfg.Scopes) == 0 {
t.Fatal("preset scopes should be populated")
}
}
func TestResolveConfigEnvOverridesPreset(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_ALLOW_PRESETS": "1",
"ZERO_OAUTH_XAI_CLIENT_ID": "custom-id",
"ZERO_OAUTH_XAI_SCOPES": "alpha beta",
"ZERO_OAUTH_XAI_FLOW": "device",
}
cfg, flow, err := r.ResolveConfig("xai", env)
if err != nil {
t.Fatalf("ResolveConfig(xai, env): %v", err)
}
if cfg.ClientID != "custom-id" {
t.Fatalf("env should override client_id, got %q", cfg.ClientID)
}
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "alpha" {
t.Fatalf("env should override scopes, got %v", cfg.Scopes)
}
if flow != FlowDevice {
t.Fatalf("env should override flow, got %q", flow)
}
// A field not overridden still comes from the preset.
if cfg.TokenEndpoint != "https://auth.x.ai/oauth2/token" {
t.Fatalf("non-overridden token endpoint = %q", cfg.TokenEndpoint)
}
}
func TestResolveConfigNoPresetStillRequiresEnv(t *testing.T) {
r := NewRegistry()
if _, _, err := r.ResolveConfig("acme-no-preset", map[string]string{}); err == nil {
t.Fatal("a provider with neither preset nor env config should error")
}
}
// Without the opt-in flag the xAI preset must stay inert: no third-party client
// identity is baked into the default credential path, and the error points the
// user at the opt-in.
func TestResolveConfigPresetInertWithoutOptIn(t *testing.T) {
r := NewRegistry()
_, _, err := r.ResolveConfig("xai", map[string]string{})
if err == nil {
t.Fatal("xai must not resolve from the preset unless ZERO_OAUTH_ALLOW_PRESETS is set")
}
if !strings.Contains(err.Error(), "ZERO_OAUTH_ALLOW_PRESETS") {
t.Fatalf("error should point at the opt-in, got: %v", err)
}
}
-117
View File
@@ -1,117 +0,0 @@
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
}
-97
View File
@@ -1,97 +0,0 @@
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)
}
}
-125
View File
@@ -1,125 +0,0 @@
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
}
}
-72
View File
@@ -1,72 +0,0 @@
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())
}
-505
View File
@@ -1,505 +0,0 @@
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 ""
}
-113
View File
@@ -1,113 +0,0 @@
package oauth
import (
"strings"
"testing"
)
// fakeKR is an in-memory KeyringClient for exercising the keyring backend
// without touching a real OS keychain.
type fakeKR struct{ data map[string]string }
func newFakeKR() *fakeKR { return &fakeKR{data: map[string]string{}} }
func (f *fakeKR) Get(service, account string) (string, bool, error) {
v, ok := f.data[service+"/"+account]
return v, ok, nil
}
func (f *fakeKR) Set(service, account, secret string) error {
f.data[service+"/"+account] = secret
return nil
}
func (f *fakeKR) Delete(service, account string) (bool, error) {
key := service + "/" + account
_, ok := f.data[key]
delete(f.data, key)
return ok, nil
}
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
// Keep the cross-process keyring lock file inside a temp config dir.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatalf("NewStore(keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("FilePath = %q, want keyring identifier", s.FilePath())
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r"}); err != nil {
t.Fatalf("Save: %v", err)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok {
t.Fatalf("Load: ok=%v err=%v", ok, err)
}
if got.AccessToken != "a" || got.RefreshToken != "r" {
t.Fatalf("Load = %#v", got)
}
// The blob is stored base64-encoded, so the raw JSON field names never appear.
raw := kr.data[keyringService+"/"+keyringAccount]
if raw == "" {
t.Fatal("nothing stored in keyring")
}
if strings.Contains(raw, "access_token") {
t.Fatalf("keyring blob is not encoded: %s", raw)
}
removed, err := s.Delete(ProviderKey("demo"))
if err != nil || !removed {
t.Fatalf("Delete: removed=%v err=%v", removed, err)
}
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
t.Fatal("token still present after delete")
}
}
func TestNewStoreStorageSelection(t *testing.T) {
// Unknown storage is rejected (fail closed).
if _, err := NewStore(StoreOptions{Storage: "bogus"}); err == nil {
t.Fatal("unknown storage should error")
}
// ZERO_OAUTH_STORAGE selects the keyring (with an injected client).
s, err := NewStore(StoreOptions{
Env: map[string]string{"ZERO_OAUTH_STORAGE": "keyring"},
Keyring: newFakeKR(),
})
if err != nil {
t.Fatalf("NewStore(env keyring): %v", err)
}
if !strings.HasPrefix(s.FilePath(), "keyring:") {
t.Fatalf("env did not select keyring backend: %q", s.FilePath())
}
// Default is the file backend.
fileStore, err := NewStore(StoreOptions{FilePath: t.TempDir() + "/oauth-tokens.json"})
if err != nil {
t.Fatalf("NewStore(file): %v", err)
}
if strings.HasPrefix(fileStore.FilePath(), "keyring:") {
t.Fatalf("default backend should be file, got %q", fileStore.FilePath())
}
}
func TestStoreKeyringStatus(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
kr := newFakeKR()
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
if err != nil {
t.Fatal(err)
}
if err := s.Save(ProviderKey("demo"), Token{AccessToken: "a"}); err != nil {
t.Fatal(err)
}
statuses, err := s.Status(KeyPrefixProvider)
if err != nil {
t.Fatal(err)
}
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") || !statuses[0].HasToken {
t.Fatalf("status = %#v", statuses)
}
}
-128
View File
@@ -1,128 +0,0 @@
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)
}
}
+2 -42
View File
@@ -47,17 +47,6 @@ type Descriptor struct {
Local bool
SupportedAPIFormats []APIFormat
Aliases []string
// OAuth reports that this provider offers an in-app OAuth login that yields a
// credential usable for model calls (browser PKCE and/or device code). Only
// set for providers where this actually works (not subscription-via-proxy).
OAuth bool
// OAuthMintsKey reports that the OAuth flow returns/mints a normal API key
// (e.g. OpenRouter) rather than a bearer token used directly.
OAuthMintsKey bool
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
// headless / SSH use) in addition to the browser flow.
OAuthDeviceFlow bool
}
func RuntimeSupported(descriptor Descriptor) bool {
@@ -88,7 +77,7 @@ var descriptors = []Descriptor{
openAICompat("ollama-cloud", "Ollama Cloud", "https://ollama.com/v1", "qwen3-coder:480b", []string{"OLLAMA_API_KEY"}, "ollama.com", "ollama cloud"),
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}),
openAICompat("groq", "Groq", "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile", []string{"GROQ_API_KEY"}),
openAICompat("deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat", []string{"DEEPSEEK_API_KEY"}),
openAICompat("together", "Together AI", "https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo", []string{"TOGETHER_API_KEY"}),
@@ -100,20 +89,13 @@ var descriptors = []Descriptor{
openAICompat("github", "GitHub Models", "https://models.inference.ai.azure.com", "openai/gpt-4.1", []string{"GITHUB_TOKEN"}, "github-models"),
transportDescriptor("bedrock", "Amazon Bedrock", TransportBedrock, "https://bedrock-runtime.${AWS_REGION}.amazonaws.com", "anthropic.claude-3-5-sonnet-20241022-v2:0", []string{"AWS_ACCESS_KEY_ID", "AWS_PROFILE"}, []APIFormat{APIFormatBedrockConverse}, true),
transportDescriptor("vertex", "Vertex AI", TransportVertex, "https://aiplatform.googleapis.com", "gemini-2.5-pro", []string{"GOOGLE_APPLICATION_CREDENTIALS"}, []APIFormat{APIFormatVertexGenerateContent}, true),
oauthProvider(openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}), false, true),
openAICompat("xai", "xAI", "https://api.x.ai/v1", "grok-4", []string{"XAI_API_KEY"}),
openAICompat("venice", "Venice AI", "https://api.venice.ai/api/v1", "qwen-2.5-qwq-32b", []string{"VENICE_API_KEY"}),
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
// the official client), so zero does not call it directly; instead point this
// at a local proxy that holds the OAuth session and exposes an OpenAI-compatible
// endpoint. Local (no API key — the proxy authenticates); override the base URL
// for your proxy's port. See docs/oauth-subscriptions.md.
localOpenAI("chatgpt-proxy", "ChatGPT (local OAuth proxy)", "http://localhost:10531/v1", "gpt-5", "chatgpt"),
openAICompat("custom-openai-compatible", "Custom OpenAI-compatible", "https://example.invalid/v1", "custom-model", []string{"OPENAI_API_KEY"}, "custom openai compatible"),
anthropicCompat("custom-anthropic-compatible", "Custom Anthropic-compatible", "https://example.invalid/anthropic", "custom-model", []string{"ANTHROPIC_API_KEY"}, "custom anthropic compatible"),
}
@@ -247,28 +229,6 @@ func google(id string, name string, baseURL string, model string, env []string,
}
}
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
descriptor.OAuth = true
descriptor.OAuthMintsKey = mintsKey
descriptor.OAuthDeviceFlow = deviceFlow
return descriptor
}
// OAuthProviders returns the catalog descriptors that support an in-app OAuth
// login, in catalog order. It is the single source of truth for the wizard's
// dedicated "Sign in with OAuth" provider list.
func OAuthProviders() []Descriptor {
out := []Descriptor{}
for _, descriptor := range descriptors {
if descriptor.OAuth {
out = append(out, cloneDescriptor(descriptor))
}
}
return out
}
func localOpenAI(id string, name string, baseURL string, model string, aliases ...string) Descriptor {
descriptor := openAICompat(id, name, baseURL, model, nil, aliases...)
descriptor.RequiresAuth = false
+1 -15
View File
@@ -33,7 +33,6 @@ var expectedCatalogIDs = []string{
"zai",
"gitlawb-opengateway",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
"custom-anthropic-compatible",
}
@@ -221,7 +220,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
@@ -278,19 +277,6 @@ func TestReturnedDescriptorsAreCopies(t *testing.T) {
}
}
func TestOAuthProviderClassification(t *testing.T) {
oauthIDs := descriptorIDs(OAuthProviders())
if want := []string{"openrouter", "xai"}; !reflect.DeepEqual(oauthIDs, want) {
t.Fatalf("OAuthProviders() = %#v, want %#v", oauthIDs, want)
}
if d, _ := Get("openrouter"); !d.OAuthMintsKey {
t.Fatal("openrouter should mint a key")
}
if d, _ := Get("xai"); !d.OAuthDeviceFlow {
t.Fatal("xai should advertise device-code flow")
}
}
func descriptorIDs(descriptors []Descriptor) []string {
ids := make([]string, 0, len(descriptors))
for _, descriptor := range descriptors {
-60
View File
@@ -1,60 +0,0 @@
package providercatalog
import "testing"
func TestOAuthProviders(t *testing.T) {
providers := OAuthProviders()
if len(providers) != 2 {
t.Fatalf("OAuthProviders() = %d, want 2 (openrouter, xai)", len(providers))
}
byID := map[string]Descriptor{}
for _, d := range providers {
if !d.OAuth {
t.Fatalf("OAuthProviders() returned a non-OAuth descriptor %q", d.ID)
}
byID[d.ID] = d
}
or, ok := byID["openrouter"]
if !ok || !or.OAuthMintsKey || or.OAuthDeviceFlow {
t.Fatalf("openrouter oauth flags wrong: %+v", or)
}
xai, ok := byID["xai"]
if !ok || xai.OAuthMintsKey || !xai.OAuthDeviceFlow {
t.Fatalf("xai oauth flags wrong: %+v", xai)
}
}
func TestOAuthProvidersReturnsIndependentClones(t *testing.T) {
first := OAuthProviders()
mutated := 0
for i := range first {
for j := range first[i].AuthEnvVars {
first[i].AuthEnvVars[j] = "MUTATED"
mutated++
}
}
if mutated == 0 {
t.Fatal("expected at least one OAuth descriptor with AuthEnvVars to mutate")
}
// A fresh call must not observe the in-place mutation: like the other catalog
// accessors, OAuthProviders must hand back clones, not shared backing slices.
for _, d := range OAuthProviders() {
for _, env := range d.AuthEnvVars {
if env == "MUTATED" {
t.Fatalf("OAuthProviders() leaked a shared AuthEnvVars slice for %q", d.ID)
}
}
}
}
func TestNonOAuthProvidersNotFlagged(t *testing.T) {
for _, d := range All() {
switch d.ID {
case "openrouter", "xai":
continue
}
if d.OAuth {
t.Fatalf("provider %q should not be flagged OAuth-capable", d.ID)
}
}
}
-172
View File
@@ -1,172 +0,0 @@
// Package provideroauth implements provider-specific OAuth login flows that don't
// fit the generic env-driven engine in internal/oauth — notably OpenRouter's
// bespoke PKCE flow, which mints a normal API key rather than returning an OAuth
// bearer token. These flows are launched from the setup wizard / CLI so a user
// can "log in with the browser" instead of pasting a key.
package provideroauth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
const (
openRouterDefaultBaseURL = "https://openrouter.ai"
openRouterAuthPath = "/auth"
openRouterKeyExchangePath = "/api/v1/auth/keys"
openRouterMaxBody = 1 << 20
)
// OpenRouterOptions configures the OpenRouter login flow.
type OpenRouterOptions struct {
// BaseURL overrides https://openrouter.ai (for tests).
BaseURL string
// HTTPClient performs the key exchange; nil => a client with a sane timeout.
HTTPClient *http.Client
// OpenBrowser is invoked with the authorize URL. When nil the URL is only
// printed (to Out) for the user to open manually.
OpenBrowser func(authURL string) error
// Out receives the "open this URL" line; nil => the URL is not printed.
Out io.Writer
// Timeout bounds the whole interactive login; 0 => 5 minutes.
Timeout time.Duration
}
// OpenRouterLogin runs OpenRouter's loopback PKCE flow and returns a freshly
// minted OpenRouter API key. The flow needs no client_id: a local callback
// captures the authorization code, which is exchanged (with the PKCE verifier)
// at /api/v1/auth/keys for a key. PKCE (S256) binds the code to this client, so
// no separate CSRF state is required. The returned key is a normal credential
// the caller stores as the provider's API key.
func OpenRouterLogin(ctx context.Context, opts OpenRouterOptions) (string, error) {
base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/")
if base == "" {
base = openRouterDefaultBaseURL
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
pkce, err := oauth.NewPKCE()
if err != nil {
return "", err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", fmt.Errorf("provideroauth: start loopback listener: %w", err)
}
defer func() { _ = listener.Close() }()
callbackURL := fmt.Sprintf("http://%s/callback", listener.Addr().String())
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
if code := strings.TrimSpace(r.URL.Query().Get("code")); code != "" {
_, _ = io.WriteString(w, "OpenRouter authorization complete. You may close this window.")
select {
case codeCh <- code:
default:
}
return
}
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
select {
case errCh <- errors.New("provideroauth: callback missing authorization code"):
default:
}
})}
go func() { _ = server.Serve(listener) }()
defer func() {
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), time.Second)
defer cancelShutdown()
_ = server.Shutdown(shutdownCtx)
}()
authURL := base + openRouterAuthPath + "?" + url.Values{
"callback_url": {callbackURL},
"code_challenge": {pkce.Challenge},
"code_challenge_method": {oauth.MethodS256},
}.Encode()
if opts.Out != nil {
fmt.Fprintf(opts.Out, "Open this URL to authorize OpenRouter:\n %s\n", authURL)
}
if opts.OpenBrowser != nil {
if err := opts.OpenBrowser(authURL); err != nil {
return "", fmt.Errorf("provideroauth: open authorization URL: %w", err)
}
}
var code string
select {
case code = <-codeCh:
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", fmt.Errorf("provideroauth: timed out waiting for OpenRouter authorization: %w", ctx.Err())
}
return openRouterExchange(ctx, client, base, code, pkce.Verifier)
}
// openRouterExchange swaps the authorization code + PKCE verifier for an API key.
func openRouterExchange(ctx context.Context, client *http.Client, base, code, verifier string) (string, error) {
payload, err := json.Marshal(map[string]string{
"code": code,
"code_verifier": verifier,
"code_challenge_method": oauth.MethodS256,
})
if err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+openRouterKeyExchangePath, bytes.NewReader(payload))
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("provideroauth: openrouter key exchange: %w", err)
}
defer func() { _ = response.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(response.Body, openRouterMaxBody))
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("provideroauth: openrouter key exchange returned HTTP %d", response.StatusCode)
}
var parsed struct {
Key string `json:"key"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("provideroauth: decode openrouter key response: %w", err)
}
key := strings.TrimSpace(parsed.Key)
if key == "" {
return "", errors.New("provideroauth: openrouter returned an empty key")
}
return key, nil
}
-131
View File
@@ -1,131 +0,0 @@
package provideroauth
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// simulateBrowser returns an OpenBrowser func that authorizes by GETting the
// callback_url with the given code (or no code), mimicking OpenRouter's redirect.
// It also asserts the PKCE params are present on the authorize URL.
func simulateBrowser(t *testing.T, code string) func(string) error {
t.Helper()
return func(authURL string) error {
u, err := url.Parse(authURL)
if err != nil {
return err
}
q := u.Query()
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Fatalf("authorize URL missing PKCE: %s", authURL)
}
cb := q.Get("callback_url")
if cb == "" {
t.Fatalf("authorize URL missing callback_url: %s", authURL)
}
target := cb
if code != "" {
target = cb + "?code=" + url.QueryEscape(code)
}
resp, err := http.Get(target) //nolint:noctx // test loopback
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
}
func TestOpenRouterLoginMintsKey(t *testing.T) {
var gotCode, gotVerifier, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/auth/keys" || r.Method != http.MethodPost {
w.WriteHeader(http.StatusNotFound)
return
}
var body map[string]string
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body)
gotCode, gotVerifier, gotMethod = body["code"], body["code_verifier"], body["code_challenge_method"]
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"key": "sk-or-mock-123"})
}))
defer srv.Close()
key, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "TESTCODE"),
})
if err != nil {
t.Fatalf("OpenRouterLogin: %v", err)
}
if key != "sk-or-mock-123" {
t.Fatalf("key = %q, want sk-or-mock-123", key)
}
if gotCode != "TESTCODE" || gotVerifier == "" || gotMethod != "S256" {
t.Fatalf("exchange body: code=%q verifier=%q method=%q", gotCode, gotVerifier, gotMethod)
}
}
func TestOpenRouterLoginMissingCodeErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, ""), // callback with no code
})
if err == nil {
t.Fatal("a callback without a code should error")
}
}
func TestOpenRouterLoginExchangeFailureErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden) // exchange rejected
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "C"),
})
if err == nil {
t.Fatal("a non-2xx exchange should error")
}
}
func TestOpenRouterLoginEmptyKeyErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"key":""}`)
}))
defer srv.Close()
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
OpenBrowser: simulateBrowser(t, "C"),
})
if err == nil || !strings.Contains(err.Error(), "empty key") {
t.Fatalf("empty key should error, got %v", err)
}
}
func TestOpenRouterLoginBrowserErrorPropagates(t *testing.T) {
_, err := OpenRouterLogin(context.Background(), OpenRouterOptions{
BaseURL: "http://127.0.0.1:1",
OpenBrowser: func(string) error { return errors.New("no browser") },
})
if err == nil {
t.Fatal("a browser-open failure should propagate")
}
}
+12 -20
View File
@@ -87,10 +87,6 @@ type Options struct {
CustomHeaders map[string]string
HTTPClient *http.Client
UserAgent string
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
// is preferred over APIKey; a nil resolver (or one that yields ok=false) uses
// the API key. See providerio.SendWithAuthRetry.
OAuthResolver providerio.TokenResolver
// StreamIdleTimeout aborts the stream if no data arrives for this long.
// Zero uses defaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
@@ -108,7 +104,6 @@ type Provider struct {
authScheme string
authHeaderValue string
customHeaders map[string]string
oauthResolver providerio.TokenResolver
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
@@ -147,7 +142,6 @@ func New(options Options) (*Provider, error) {
authScheme: strings.TrimSpace(options.AuthScheme),
authHeaderValue: strings.TrimSpace(options.AuthHeaderValue),
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
oauthResolver: options.OAuthResolver,
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
streamIdleTimeout: idleTimeout,
@@ -182,26 +176,24 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body,
providerio.AuthHeaders{
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.baseURL+"/v1/messages", body, func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
request.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
APIKey: provider.apiKey,
DefaultAuthHeader: "x-api-key",
AuthHeader: provider.authHeader,
AuthScheme: provider.authScheme,
AuthHeaderValue: provider.authHeaderValue,
CustomHeaders: provider.customHeaders,
},
provider.oauthResolver,
func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("anthropic-version", provider.version)
if provider.beta != "" {
request.Header.Set("anthropic-beta", provider.beta)
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
}, 0)
})
}, 0)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
-42
View File
@@ -10,7 +10,6 @@ import (
"github.com/Gitlawb/zero/internal/providers/anthropic"
"github.com/Gitlawb/zero/internal/providers/gemini"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -19,10 +18,6 @@ type Options struct {
UserAgent string
HTTPClient *http.Client
ModelRegistry *modelregistry.Registry
// OAuthResolver, when set, lets the provider authenticate model calls with an
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
OAuthResolver providerio.TokenResolver
}
// New creates a runtime provider for a resolved provider profile.
@@ -42,11 +37,9 @@ 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{
@@ -57,7 +50,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
@@ -80,40 +72,6 @@ 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
-27
View File
@@ -108,33 +108,6 @@ 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 258 files, more files were not shown because too many files have changed in this diff. Show more