SearXNG support
Release / release (push) Failing after 6s

This commit is contained in:
2026-06-14 21:33:22 +02:00
parent a96f2afe6f
commit 59eaf9e47d
24 changed files with 650 additions and 34 deletions
+1
View File
@@ -16,5 +16,6 @@ agent CLIs with generated MCP config, and lets MCPs authenticate lazily.
- [gitea-mcp](docs/gitea-mcp.md)
- [grafana-mcp](docs/grafana-mcp.md)
- [gssh-mcp](docs/gssh-mcp.md)
- [searxng-mcp](docs/searxng-mcp.md)
- [gssh integration](docs/gssh-integration.md)
- [Conventions](docs/conventions.md)
+1 -1
View File
@@ -1 +1 @@
0.1.5
0.1.6
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var ErrUnauthorized = errors.New("searxng: bearer rejected")
type searxngAPI struct {
baseURL string
token func(context.Context) (string, error)
client *http.Client
}
type searchParams struct {
Query string
Categories string
Engines string
Language string
TimeRange string
SafeSearch *int
Page int
Limit int
}
type searxngSearchResponse struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content,omitempty"`
Engine string `json:"engine,omitempty"`
Engines []string `json:"engines,omitempty"`
Category string `json:"category,omitempty"`
Score float64 `json:"score,omitempty"`
PublishedDate string `json:"publishedDate,omitempty"`
ImgSrc string `json:"img_src,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
}
func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) {
q := url.Values{
"format": []string{"json"},
"q": []string{p.Query},
}
setIf(q, "categories", p.Categories)
setIf(q, "engines", p.Engines)
setIf(q, "language", p.Language)
setIf(q, "time_range", p.TimeRange)
if p.SafeSearch != nil {
q.Set("safesearch", strconv.Itoa(*p.SafeSearch))
}
if p.Page > 0 {
q.Set("pageno", strconv.Itoa(p.Page))
}
full := s.baseURL + "/search?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return searxngSearchResponse{}, err
}
tok, err := s.token(ctx)
if err != nil {
return searxngSearchResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return searxngSearchResponse{}, err
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
return searxngSearchResponse{}, err
}
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct)
}
var out searxngSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err)
}
if p.Limit > 0 && len(out.Results) > p.Limit {
out.Results = out.Results[:p.Limit]
}
return out, nil
}
func checkStatus(resp *http.Response, req *http.Request) error {
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host)
}
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect {
return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location"))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setIf(q url.Values, k, v string) {
if v != "" {
q.Set(k, v)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Command searxng-mcp is sherlock's MCP server for SearXNG. It
// authenticates as the operator against Authentik (OIDC + PKCE,
// loopback callback) and sends the resulting bearer token to the
// Caddy-protected SearXNG JSON search API.
//
// Two modes:
//
// searxng-mcp start the stdio MCP server (agents spawn this)
// searxng-mcp --probe run auth + one search, print result, exit
//
// Deployment configuration (the Authentik issuer/client ID and the
// SearXNG base URL) comes entirely from the sherlock config file; there
// are no compiled-in defaults. See docs/configuration.md.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// serviceName is the wallet key under which SearXNG tokens live, and
// the [services.<serviceName>] section read from the config file. The
// sherlock CLI's `logout searxng` clears the same wallet entry.
const serviceName = "searxng"
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
// scopes asked from Authentik. `openid` is required for an ID token;
// `profile`/`email` populate user-info claims in `sherlock status`.
var scopes = []string{"openid", "profile", "email"}
func main() {
probe := flag.Bool("probe", false, "run auth + one SearXNG search, print result, exit")
probeQuery := flag.String("probe-query", "sherlock", "query to use with --probe")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Println(Version)
return
}
svc, err := config.LoadService(serviceName)
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
if errors.Is(err, config.ErrNotFound) {
fmt.Fprintln(os.Stderr, "Create it (see docs/configuration.md) or run the sherlock install script.")
}
os.Exit(2)
}
store, err := keyring.Open()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(3)
}
agent.EnsurePathContainsSiblings()
lockPath, err := xdg.RefreshLockPath()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
cfg := authn.Config{
Issuer: svc.Issuer,
ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret,
Scopes: scopes,
}
holder := authn.NewTokenHolder()
src := authn.NewTokenSource(store, serviceName, cfg, authn.EnsureOptions{
LockPath: lockPath,
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.Open(u); err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
}
},
}, holder.Set).Lifetime(ctx)
// Lazy auth: the token getter triggers the (possibly browser-opening)
// OAuth flow on the first tool call and launches the background
// renewer. Auth stays off the MCP startup path so the handshake
// completes instantly.
token := func(reqCtx context.Context) (string, error) {
if err := src.EnsureStarted(reqCtx); err != nil {
return "", fmt.Errorf("searxng auth: %w", err)
}
return holder.AccessToken(), nil
}
api := &searxngAPI{
baseURL: strings.TrimRight(svc.BaseURL, "/"),
token: token,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
if *probe {
// --probe authenticates eagerly: that's the whole point of a probe.
if err := src.EnsureStarted(ctx); err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: auth:", err)
os.Exit(5)
}
resp, err := api.search(ctx, searchParams{Query: *probeQuery, Limit: 1})
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp: probe:", err)
os.Exit(5)
}
fmt.Printf("OK: searched %s for %q (%d result(s))\n", svc.BaseURL, *probeQuery, len(resp.Results))
return
}
server := mcp.NewServer(&mcp.Implementation{Name: "searxng-mcp", Version: Version}, nil)
registerTools(server, api)
if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(1)
}
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"context"
"errors"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerTools(server *mcp.Server, api *searxngAPI) {
mcp.AddTool(server, &mcp.Tool{
Name: "search_web",
Description: "Search the operator's OAuth-protected SearXNG instance and return JSON search results.",
}, searchWebHandler(api))
}
type searchWebInput struct {
Query string `json:"query" jsonschema:"search query"`
Categories string `json:"categories,omitempty" jsonschema:"optional comma-separated SearXNG categories, e.g. general, images, news"`
Engines string `json:"engines,omitempty" jsonschema:"optional comma-separated SearXNG engines"`
Language string `json:"language,omitempty" jsonschema:"optional SearXNG language code"`
TimeRange string `json:"time_range,omitempty" jsonschema:"optional time range: day, month, year"`
SafeSearch *int `json:"safesearch,omitempty" jsonschema:"optional SearXNG safesearch value: 0, 1, or 2"`
Page int `json:"page,omitempty" jsonschema:"optional result page number, default 1"`
Limit int `json:"limit,omitempty" jsonschema:"max results to return (1-50, default 10)"`
}
type searchWebOutput struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results,omitempty"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
func searchWebHandler(api *searxngAPI) mcp.ToolHandlerFor[searchWebInput, searchWebOutput] {
return func(ctx context.Context, _ *mcp.CallToolRequest, in searchWebInput) (*mcp.CallToolResult, searchWebOutput, error) {
query := strings.TrimSpace(in.Query)
if query == "" {
return nil, searchWebOutput{}, errors.New("search_web requires query")
}
limit := clampInt(in.Limit, 1, 50, 10)
page := clampInt(in.Page, 1, 100, 1)
if in.SafeSearch != nil && (*in.SafeSearch < 0 || *in.SafeSearch > 2) {
return nil, searchWebOutput{}, errors.New("safesearch must be 0, 1, or 2")
}
resp, err := api.search(ctx, searchParams{
Query: query,
Categories: strings.TrimSpace(in.Categories),
Engines: strings.TrimSpace(in.Engines),
Language: strings.TrimSpace(in.Language),
TimeRange: strings.TrimSpace(in.TimeRange),
SafeSearch: in.SafeSearch,
Page: page,
Limit: limit,
})
if err != nil {
return nil, searchWebOutput{}, err
}
return nil, searchWebOutput{
Query: resp.Query,
NumberOfResults: resp.NumberOfResults,
Results: resp.Results,
Suggestions: resp.Suggestions,
Answers: resp.Answers,
Corrections: resp.Corrections,
}, nil
}
}
func clampInt(v, min, max, dflt int) int {
if v <= 0 {
return dflt
}
if v < min {
return min
}
if v > max {
return max
}
return v
}
+6
View File
@@ -132,6 +132,11 @@ func runStatus(store keyring.Store, _ []string) {
if len(ts.Scopes) > 0 {
fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " "))
}
if !ts.AccessExpAt.IsZero() {
fmt.Printf(" access until %s (%s)\n",
ts.AccessExpAt.Format(time.RFC3339),
humanizeUntil(ts.AccessExpAt))
}
if !ts.IDExpiresAt.IsZero() {
fmt.Printf(" id token until %s (%s)\n",
ts.IDExpiresAt.Format(time.RFC3339),
@@ -220,6 +225,7 @@ func knownMCPs() (mcp.Servers, error) {
"gitea": {Command: "gitea-mcp"},
"grafana": {Command: "grafana-mcp"},
"gssh": {Command: "gssh-mcp"},
"searxng": {Command: "searxng-mcp"},
}
for name, spec := range specs {
abs, err := agent.LookPath(spec.Command)
+4 -3
View File
@@ -78,9 +78,10 @@ func runUpdate(args []string) {
}
// Non-interactive install against the cache checkout (the installer
// syncs it to the latest tag and bakes that version). Config is left
// untouched — SeedConfig is off, so an update never opens an editor
// or clobbers config.
// syncs it to the latest tag and bakes that version). SeedConfig is
// off, so update never creates a config or opens an editor, but the
// installer may append marked templates for newly shipped config
// sections. Existing values are never clobbered.
if err := installer.Install(context.Background(), installer.Options{
Local: false,
SeedConfig: false,
+1
View File
@@ -26,6 +26,7 @@ complete -c sherlock -n '__fish_seen_subcommand_from run' -f -a 'claude' -d 'An
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gitea' -d 'Forget the gitea session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'grafana' -d 'Forget the grafana session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'gssh' -d 'Forget the gssh session'
complete -c sherlock -n '__fish_seen_subcommand_from logout' -f -a 'searxng' -d 'Forget the searxng session'
# ── `sherlock update [--force]` ──────────────────────────────────────
complete -c sherlock -n '__fish_seen_subcommand_from update' -l force -s f -f -d 'Reinstall the latest release even if not newer'
+4
View File
@@ -36,3 +36,7 @@ base_url = "https://grafana.example.com"
[services.gssh]
provider = "sherlock-cli"
base_url = "https://terminal.example.com"
[services.searxng]
provider = "sherlock-cli"
base_url = "https://search.example.com"
+3 -2
View File
@@ -13,8 +13,9 @@ equivalent. Unknown agent names exit with usage errors.
## Spawn behavior
On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`,
`gssh-mcp`), skips missing ones with a warning, renders a 0600 `.mcp.json` file
under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target agent.
`gssh-mcp`, `searxng-mcp`), skips missing ones with a warning, renders a 0600
`.mcp.json` file under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target
agent.
The child mostly inherits the parent environment. The Claude profile strips
`ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed
+7 -6
View File
@@ -5,7 +5,7 @@ master session: each MCP authenticates for the service it calls.
## Lifecycle
For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks
`internal/authn` for a token on first use:
1. Fresh wallet entry: return it.
@@ -15,17 +15,18 @@ For a service such as `gitea`, `grafana`, or `gssh`, the MCP asks
serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return.
Long-running MCPs use `TokenSource` and `TokenHolder` so requests always read
the latest bearer. Refresh never opens a browser; if refresh can no longer
recover, the next invocation performs a fresh login.
the latest bearer. Refresh follows access-token expiry, never opens a browser,
and falls back to a fresh login on the next invocation if refresh can no longer
recover.
## Service identity
The config supplies issuer, client ID/secret, and base URL. Scopes are owned by
each MCP because they follow the tool surface, not the deployment.
Gitea uses Gitea's OAuth2 server. Grafana and Gssh normally reuse the shared
Authentik `sherlock-cli` provider. Tokens are stored and refreshed per service
and are not reused across unrelated services.
Gitea uses Gitea's OAuth2 server. Grafana, Gssh, and SearXNG normally reuse the
shared Authentik `sherlock-cli` provider. Tokens are stored and refreshed per
service and are not reused across unrelated services.
## User controls
+4
View File
@@ -5,6 +5,10 @@ required fields are hard errors.
Default source: [`config.example.toml`](../config.example.toml).
Setup and update compare an existing config against the shipped example. Missing
provider/service sections are appended as marked templates; existing sections
and values are not rewritten.
## Location
Resolved in order:
+3 -2
View File
@@ -30,9 +30,10 @@ Repository rules that should stay true as the project changes.
## Naming
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`.
- Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`, `searxng-mcp`.
- Agent names match the wrapped CLI: `copilot`, `claude`.
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`.
- Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`,
`searxng`.
## Commits and CI
+6 -4
View File
@@ -15,8 +15,9 @@ around the same command.
The installer clones or updates a cached source checkout, checks out the latest
release tag, seeds `config.toml` from `config.example.toml` without overwriting
existing values, optionally opens the config in an editor, installs `sherlock`
plus MCP binaries, and installs shell completions when supported.
existing values, appends marked templates for missing service/provider sections,
optionally opens the config in an editor, installs `sherlock` plus MCP binaries,
and installs shell completions when supported.
One implementation backs both bootstrap setup and `sherlock update`.
@@ -32,7 +33,8 @@ selects the editor.
## Updating
Use `sherlock update` to install a newer release, or `sherlock update --force`
to reinstall the latest release. Updates rebuild binaries and completions but do
not touch existing config.
to reinstall the latest release. Updates rebuild binaries and completions, and
may append marked config templates for newly shipped service/provider sections.
They do not rewrite existing config values.
Re-running setup is safe: it preserves config and reinstalls binaries.
+23
View File
@@ -0,0 +1,23 @@
# searxng-mcp
Stdio MCP for a SearXNG instance. It exposes web search through SearXNG's JSON
`/search` API. Exact tool schemas live in `cmd/searxng-mcp/tools.go`.
## Auth
`searxng-mcp` uses sherlock-managed OAuth with wallet key `searxng`, normally
through the shared Authentik `sherlock-cli` provider. It sends a fresh bearer to
the Caddy-protected SearXNG origin on every search request.
Caddy must accept that OAuth bearer for API requests. Browser SSO redirects or
session cookies alone are not enough for an MCP subprocess. SearXNG must also
enable JSON output for `/search?format=json`.
Config is `[services.searxng]` with provider or inline OAuth identity plus
`base_url`. `sherlock logout searxng` clears the session.
## Operation
When `searxng-mcp` is installed, `sherlock <agent>` includes it in the generated
MCP config. The first tool call authenticates lazily. `searxng-mcp --probe`
verifies auth against Caddy and one SearXNG JSON search without an agent.
+9
View File
@@ -205,6 +205,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry,
AccessExpAt: accessExpiry(tok.Expiry, idTok.Expiry),
RefreshExpAt: refreshExpiry(tok),
Issuer: cfg.Issuer,
ClientID: cfg.ClientID,
@@ -239,12 +240,20 @@ func splitScope(s string) []string {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
}
}
return out
}
func accessExpiry(access, fallback time.Time) time.Time {
if !access.IsZero() {
return access
}
return fallback
}
// refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
// to RFC 6749). If absent we conservatively assume 30 days, matching
// Authentik's default.
+3
View File
@@ -246,6 +246,9 @@ func TestLogin_HappyPath(t *testing.T) {
if res.Tokens.ClientID != "test-client" {
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
}
if res.Tokens.AccessExpAt.IsZero() {
t.Fatal("AccessExpAt was not persisted")
}
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
}
+16 -5
View File
@@ -17,8 +17,8 @@ import (
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
)
// RefreshSkew is how much headroom EnsureFresh leaves before the ID
// token actually expires. Refreshing 30s ahead of expiry avoids
// RefreshSkew is how much headroom EnsureFresh leaves before the
// bearer token actually expires. Refreshing 30s ahead of expiry avoids
// shipping a token that will be rejected by clock-skewed resource
// servers.
const RefreshSkew = 30 * time.Second
@@ -39,7 +39,7 @@ type EnsureFreshOptions struct {
}
// EnsureFresh returns the stored TokenSet for service, refreshing it
// against its OAuth issuer if the ID token has < RefreshSkew left.
// against its OAuth issuer if the bearer token has < RefreshSkew left.
// Cross-process safe: takes an exclusive flock on opts.LockPath,
// re-reads the keyring after acquiring the lock, and skips the
// refresh if a concurrent process already rotated the tokens.
@@ -68,7 +68,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
if ts.Empty() {
return ts, keyring.ErrNoTokens
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil
}
@@ -93,7 +93,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
if err != nil {
return ts, err
}
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) {
if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil
}
if ts.RefreshToken == "" {
@@ -151,7 +151,18 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key
out.RefreshToken = tok.RefreshToken
}
out.IDExpiresAt = idTok.Expiry
out.AccessExpAt = accessExpiry(tok.Expiry, idTok.Expiry)
out.RefreshExpAt = refreshExpiry(tok)
out.Scopes = grantedScopes(tok, ts.Scopes)
return out, nil
}
func expiryForRefresh(ts keyring.TokenSet) time.Time {
if !ts.AccessExpAt.IsZero() {
return ts.AccessExpAt
}
if ts.AccessToken != "" && ts.RefreshToken != "" {
return time.Time{}
}
return ts.IDExpiresAt
}
+2 -2
View File
@@ -197,7 +197,7 @@ func (s *TokenSource) Run(ctx context.Context) error {
}
// nextWait computes how long to sleep before the next proactive
// renewal: skew before the cached token expires, clamped to minWait so
// renewal: skew before the bearer token expires, clamped to minWait so
// a very short TTL doesn't spin.
func (s *TokenSource) nextWait() time.Duration {
s.mu.Lock()
@@ -206,7 +206,7 @@ func (s *TokenSource) nextWait() time.Duration {
if cur.Empty() {
return s.minWait
}
wait := cur.IDExpiresAt.Sub(s.clock()) - s.skew
wait := expiryForRefresh(cur).Sub(s.clock()) - s.skew
if wait < s.minWait {
return s.minWait
}
+9 -3
View File
@@ -73,13 +73,16 @@ func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
stub := newStubAuthentik(t)
store := fakekeyring.New()
// Seed a STALE token (already expired) with a refresh token so the
// renewal path fires immediately.
// Seed a stale bearer with a still-fresh ID token. This mirrors
// providers that issue short-lived access tokens and longer-lived ID
// tokens; refresh must follow the bearer lifetime because MCPs send
// the access token to services.
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "old",
AccessToken: "at-stale",
RefreshToken: "rt-1",
IDExpiresAt: time.Now().Add(-time.Minute),
AccessExpAt: time.Now().Add(-time.Minute),
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid"},
@@ -114,6 +117,9 @@ func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
if stored.AccessToken != "at-refreshed" {
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken)
}
if stored.AccessExpAt.IsZero() {
t.Fatal("wallet access token expiry was not persisted")
}
cancel()
select {
+108
View File
@@ -53,6 +53,114 @@ func seedConfig(opts *Options, example, cfgPath string) error {
return os.WriteFile(cfgPath, data, 0o600)
}
// appendMissingConfigSections compares the operator config with the
// shipped example and appends any missing [providers.*] or [services.*]
// sections as clearly marked templates. It never rewrites existing
// sections, so operator edits are preserved.
func appendMissingConfigSections(opts *Options, example, cfgPath string) ([]string, error) {
cfg, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
opts.warn("no config found at %s; run setup with --config-only to create one.", cfgPath)
return nil, nil
}
return nil, err
}
exampleBody, err := os.ReadFile(example)
if err != nil {
return nil, err
}
existing := sectionSet(string(cfg))
blocks := configSectionBlocks(string(exampleBody))
var missing []configSectionBlock
for _, b := range blocks {
if !existing[b.Name] {
missing = append(missing, b)
}
}
if len(missing) == 0 {
return nil, nil
}
var out strings.Builder
out.WriteString(string(cfg))
if len(cfg) > 0 && cfg[len(cfg)-1] != '\n' {
out.WriteByte('\n')
}
for _, b := range missing {
out.WriteString("\n# Added by sherlock because this section was missing from config.toml.\n")
out.WriteString("# Template copied from config.example.toml; fill placeholders before use.\n")
out.WriteString(strings.TrimSpace(b.Body))
out.WriteByte('\n')
}
if err := os.WriteFile(cfgPath, []byte(out.String()), 0o600); err != nil {
return nil, err
}
names := make([]string, 0, len(missing))
for _, b := range missing {
names = append(names, b.Name)
}
return names, nil
}
type configSectionBlock struct {
Name string
Body string
}
func sectionSet(body string) map[string]bool {
out := map[string]bool{}
for _, line := range strings.Split(body, "\n") {
if name, ok := configSectionName(line); ok {
out[name] = true
}
}
return out
}
func configSectionBlocks(body string) []configSectionBlock {
lines := strings.Split(body, "\n")
var blocks []configSectionBlock
for i := 0; i < len(lines); i++ {
name, ok := configSectionName(lines[i])
if !ok {
continue
}
start := i
end := len(lines)
for j := i + 1; j < len(lines); j++ {
if _, ok := configSectionName(lines[j]); ok {
end = j
break
}
}
blocks = append(blocks, configSectionBlock{
Name: name,
Body: strings.Join(lines[start:end], "\n"),
})
i = end - 1
}
return blocks
}
func configSectionName(line string) (string, bool) {
s := strings.TrimSpace(line)
if !strings.HasPrefix(s, "[") {
return "", false
}
end := strings.IndexByte(s, ']')
if end < 0 {
return "", false
}
name := s[1:end]
if strings.HasPrefix(name, "providers.") || strings.HasPrefix(name, "services.") {
return name, true
}
return "", false
}
// pickEditor resolves the editor command: $VISUAL, then $EDITOR, then
// the first of sensible-editor/nano/vi found on PATH. Returns "" if none.
func pickEditor() string {
+19 -6
View File
@@ -31,7 +31,7 @@ const devVersion = "0.0.0-dev"
// binaries are the cmd/<name> packages installed. `go install ./cmd/...`
// covers exactly these (the setup entry lives outside cmd/, so it is
// never installed).
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp"}
var binaries = []string{"sherlock", "gitea-mcp", "grafana-mcp", "gssh-mcp", "searxng-mcp"}
// Options controls one Install run.
type Options struct {
@@ -49,7 +49,8 @@ type Options struct {
// SeedConfig copies config.example.toml to the operator's config
// path when none exists yet (never clobbers a filled-in file).
SeedConfig bool
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding.
// OpenEditor opens $VISUAL/$EDITOR on the config after seeding and
// appending any missing section templates.
OpenEditor bool
// ConfigOnly stops after the config step (no build/install).
ConfigOnly bool
@@ -97,14 +98,26 @@ func Install(ctx context.Context, opts Options) error {
return fmt.Errorf("config.example.toml not found in the source (%s): %w", example, err)
}
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if opts.SeedConfig {
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if err := seedConfig(&opts, example, cfgPath); err != nil {
return err
}
}
addedSections, err := appendMissingConfigSections(&opts, example, cfgPath)
if err != nil {
return err
}
if len(addedSections) > 0 {
opts.warn("%s was missing section(s): %s. Appended marked templates from config.example.toml; fill placeholders before use.",
cfgPath, strings.Join(addedSections, ", "))
}
if opts.SeedConfig {
if opts.OpenEditor {
if err := openEditor(&opts, cfgPath); err != nil {
return err
+63
View File
@@ -3,6 +3,7 @@ package installer
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -70,6 +71,68 @@ func TestSeedConfig_DoesNotClobber(t *testing.T) {
}
}
func TestAppendMissingConfigSections(t *testing.T) {
dir := t.TempDir()
example := filepath.Join(dir, "config.example.toml")
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(example, []byte(`
[providers.sherlock-cli]
issuer = "https://id.example/"
client_id = "REPLACE"
[services.gitea]
issuer = "https://gitea.example"
client_id = "gitea"
base_url = "https://gitea.example"
[services.searxng]
provider = "sherlock-cli"
base_url = "https://search.example"
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfg, []byte(`
[providers.sherlock-cli]
issuer = "https://id.real/"
client_id = "real"
[services.gitea]
issuer = "https://gitea.real"
client_id = "gitea-real"
base_url = "https://gitea.real"
`), 0o600); err != nil {
t.Fatal(err)
}
opts := &Options{Out: io_discard{}, Err: io_discard{}}
added, err := appendMissingConfigSections(opts, example, cfg)
if err != nil {
t.Fatalf("appendMissingConfigSections: %v", err)
}
if len(added) != 1 || added[0] != "services.searxng" {
t.Fatalf("added = %v, want [services.searxng]", added)
}
b, err := os.ReadFile(cfg)
if err != nil {
t.Fatal(err)
}
body := string(b)
if !strings.Contains(body, "Added by sherlock") {
t.Fatalf("missing marker comment in %q", body)
}
if strings.Count(body, "[services.searxng]") != 1 {
t.Fatalf("searxng section count wrong in %q", body)
}
added, err = appendMissingConfigSections(opts, example, cfg)
if err != nil {
t.Fatalf("second appendMissingConfigSections: %v", err)
}
if len(added) != 0 {
t.Fatalf("second added = %v, want none", added)
}
}
func TestFishCompletionsDir(t *testing.T) {
tmp := t.TempDir()
t.Setenv("__fish_config_dir", "")
+1
View File
@@ -139,6 +139,7 @@ type TokenSet struct {
IDToken string `json:"id_token"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
AccessExpAt time.Time `json:"access_expires_at,omitempty"`
IDExpiresAt time.Time `json:"id_expires_at"`
RefreshExpAt time.Time `json:"refresh_expires_at"`
Issuer string `json:"issuer"`