Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e8186b46 | |||
| c943b1f4fb | |||
| 59eaf9e47d | |||
| a96f2afe6f | |||
| 31829b9733 | |||
| 46e1e0d8ef |
@@ -1,6 +1,8 @@
|
||||
# sherlock
|
||||
|
||||
Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Holds one OAuth session per service in the OS keyring, exposes a shared auth library that MCPs call lazily at startup, and execs the agent CLI of your choice (Copilot today, Claude Code or any other MCP-aware agent tomorrow).
|
||||
Per-operator credential wallet and agent wrapper for the Charlie homelab.
|
||||
Sherlock stores service OAuth sessions in the OS keyring, launches supported
|
||||
agent CLIs with generated MCP config, and lets MCPs authenticate lazily.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -14,5 +16,6 @@ Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Hold
|
||||
- [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)
|
||||
|
||||
@@ -117,7 +117,7 @@ func main() {
|
||||
OnAuthURL: func(u string) {
|
||||
fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:")
|
||||
fmt.Fprintln(os.Stderr, " ", u)
|
||||
if err := browser.Open(u); err != nil {
|
||||
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gitea-mcp: open browser:", err)
|
||||
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func main() {
|
||||
OnAuthURL: func(u string) {
|
||||
fmt.Fprintln(os.Stderr, "grafana-mcp: opening browser for Grafana OAuth login:")
|
||||
fmt.Fprintln(os.Stderr, " ", u)
|
||||
if err := browser.Open(u); err != nil {
|
||||
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "grafana-mcp: open browser:", err)
|
||||
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func main() {
|
||||
OnAuthURL: func(u string) {
|
||||
fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:")
|
||||
fmt.Fprintln(os.Stderr, " ", u)
|
||||
if err := browser.Open(u); err != nil {
|
||||
if err := browser.OpenWith(svc.OAuthBrowser, u); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gssh-mcp: open browser:", err)
|
||||
fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.OpenWith(svc.OAuthBrowser, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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(resp), 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# sherlock conifugration - lives in $XDG_CONFIG_HOME/sherlock/config.toml
|
||||
|
||||
# ── OAuth login ───────────────────────────────────────────────────────
|
||||
# Optional browser executable/name for OAuth login URLs. Leave unset to
|
||||
# use the OS default browser (xdg-open/open/rundll32).
|
||||
# [oauth]
|
||||
# browser = "firefox"
|
||||
|
||||
# ── Providers ────────────────────────────────────────────────────────
|
||||
# A [providers.<name>] block is a reusable OAuth/OIDC client. Services
|
||||
# point at one via `provider = "<name>"`. Define a provider once and
|
||||
@@ -36,3 +42,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"
|
||||
|
||||
+20
-88
@@ -1,97 +1,29 @@
|
||||
# Agents
|
||||
|
||||
How sherlock decides which CLI to spawn when you type
|
||||
`sherlock copilot` or `sherlock claude`, and how to add a new one.
|
||||
Sherlock dispatches built-in agent profiles from `internal/agent/`.
|
||||
|
||||
## Supported today
|
||||
| Agent | CLI | MCP config |
|
||||
| --------- | ------------------ | --------------------------------- |
|
||||
| `copilot` | GitHub Copilot CLI | `--additional-mcp-config @<path>` |
|
||||
| `claude` | Claude Code CLI | `--mcp-config <path>` |
|
||||
|
||||
| Name | Binary | MCP config flag | Notes |
|
||||
|---|---|---|---|
|
||||
| `copilot` | `copilot` (npm `@github/copilot`) | `--additional-mcp-config @<path>` | Augments user's `~/.copilot/mcp-config.json`. JSON shape is the canonical `.mcp.json` schema (`{"mcpServers": ...}`). |
|
||||
| `claude` | `claude` (npm `@anthropic-ai/claude-code`) | `--mcp-config <path>` | Same `{"mcpServers": ...}` shape. `ANTHROPIC_API_KEY` is stripped from the child env so a personal key can't override the sherlock-managed session. |
|
||||
`sherlock <agent> [args...]` and `sherlock run <agent> [args...]` are
|
||||
equivalent. Unknown agent names exit with usage errors.
|
||||
|
||||
## Routing
|
||||
## Spawn behavior
|
||||
|
||||
```
|
||||
sherlock copilot [args...] ⇢ runs copilot
|
||||
sherlock claude [args...] ⇢ runs claude
|
||||
sherlock run copilot [args...] ⇢ same, explicit form
|
||||
sherlock run <unknown> ... ⇢ exit 2 with "unknown agent"
|
||||
sherlock <unknown> ⇢ exit 2 with "unknown subcommand"
|
||||
```
|
||||
On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`,
|
||||
`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 `run` form exists for parity with `cargo run` / `npm run`; the
|
||||
bare alias is the daily-driver form.
|
||||
The child mostly inherits the parent environment. The Claude profile strips
|
||||
`ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed
|
||||
behavior.
|
||||
|
||||
## What sherlock does per spawn
|
||||
## Changing agents
|
||||
|
||||
1. `keyring.Open()` — fail fast if the OS keyring isn't available (returns `*UnavailableError` with a remediation `Hint` field).
|
||||
2. Resolve the agent binary on `$PATH`. Friendly error if missing.
|
||||
3. Render the per-agent MCP config to `$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` (0600). In Phase 1 the servers map is always empty; Phase 2 populates it from `services.d/`.
|
||||
4. Build the child argv with the agent-specific flag.
|
||||
5. Build the child env: parent env minus per-agent forbids. MCPs spawned by the agent will reach into the OS keyring (via `internal/authn.Ensure`) on their own at startup — sherlock does not pre-authenticate anything.
|
||||
6. `syscall.Exec` — sherlock disappears, the agent takes its place.
|
||||
|
||||
## Adding a new agent
|
||||
|
||||
It's a code change, deliberately. The TOML-overlay design was tried
|
||||
and scrapped: each CLI has enough idiosyncrasies (auth subcommands,
|
||||
permission flags, MCP config schema, env var quirks) that a Go file
|
||||
per agent is honest about the surface area and gives those quirks a
|
||||
real place to live.
|
||||
|
||||
Drop a new file in `internal/agent/`:
|
||||
|
||||
```go
|
||||
// internal/agent/aider.go
|
||||
package agent
|
||||
|
||||
import "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
|
||||
|
||||
func init() { Register(&aider{}) }
|
||||
|
||||
type aider struct{}
|
||||
|
||||
func (aider) Name() string { return "aider" }
|
||||
func (aider) Description() string { return "Aider AI pair programmer" }
|
||||
|
||||
func (a aider) Spawn(ctx Context, args []string) error {
|
||||
bin, err := LookPath("aider")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Aider's MCP schema and flag would go here.
|
||||
_ = bin
|
||||
_ = ctx
|
||||
_ = args
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
That's the whole API: `Name`, `Description`, `Spawn`. The CLI picks
|
||||
it up automatically through the `init()` registry call; `sherlock
|
||||
status` shows it; `sherlock aider ...` dispatches.
|
||||
|
||||
## Reusable helpers
|
||||
|
||||
Available to every agent implementation in this package:
|
||||
|
||||
| Helper | Purpose |
|
||||
|---|---|
|
||||
| `LookPath(name)` | `exec.LookPath` with a sherlock-friendly error message. |
|
||||
| `BuildEnv(forbid, set)` | parent env minus `forbid`, plus `set`. |
|
||||
| `DefaultExecer` | the package-level `Execer` (swap in tests). |
|
||||
| `mcp.Render(name, servers)` | writes `{"mcpServers": ...}` to `$XDG_RUNTIME_DIR/sherlock/<name>.mcp.json`. |
|
||||
|
||||
If a new agent needs a third MCP-config schema, add a new `Render*`
|
||||
function to `internal/mcp/` rather than open-coding JSON in the agent
|
||||
file.
|
||||
|
||||
## What sherlock does *not* do
|
||||
|
||||
- Read agent config from `~/.config/sherlock/agents.d/` — that
|
||||
directory does not exist.
|
||||
- Hot-reload registered agents — the registry is sealed at process
|
||||
start, by design (one fewer code path).
|
||||
- Sandbox the agent — sherlock just `exec`s it, the agent inherits
|
||||
the user's full environment minus a few targeted forbids.
|
||||
Adding an agent is a code change under `internal/agent/`. Implement the small
|
||||
agent interface, register it in `init`, and use `internal/mcp` for config
|
||||
rendering. The exact API lives in `internal/agent/agent.go`; shared spawn
|
||||
helpers live in `internal/agent/exec.go`.
|
||||
|
||||
+23
-58
@@ -1,67 +1,32 @@
|
||||
# Architecture
|
||||
|
||||
Start here for the high-level picture; the other `docs/*.md` files cover each concern in depth.
|
||||
Sherlock is a local CLI, not a daemon. It either manages the wallet (`status`,
|
||||
`logout`) or replaces itself with an agent CLI (`copilot`, `claude`, ...). MCPs
|
||||
run as agent child processes and own service authentication.
|
||||
|
||||
## One-paragraph summary
|
||||
## Runtime flow
|
||||
|
||||
Sherlock is a per-user credential broker + agent-CLI wrapper that runs on the operator's workstation. It owns a single Authentik session (persisted in the OS keyring), exchanges it for per-service tokens on demand, injects those tokens as environment variables into thin stdio MCP servers, and then `exec`s the agent CLI of your choice (Copilot, Claude Code, …) with the right MCP config. No long-lived service tokens live on disk in the clear, and the agent never sees a credential it isn't supposed to.
|
||||
1. `sherlock <agent>` opens the keyring, resolves installed MCP binaries, writes
|
||||
`$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json`, and `exec`s the agent.
|
||||
2. The agent starts the configured stdio MCPs.
|
||||
3. Each MCP loads its `[services.<name>]` config, authenticates on first tool
|
||||
call, keeps its token fresh, and calls the target service.
|
||||
|
||||
## Diagram
|
||||
## Code map
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
user["user shell"] -->|`sherlock copilot`| sh[sherlock CLI]
|
||||
|
||||
sh -->|syscall.Exec| agent[copilot / claude CLI]
|
||||
sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json)]
|
||||
agent -.->|reads| mcpfile
|
||||
|
||||
agent -->|stdio| giteamcp[gitea-mcp]
|
||||
agent -->|stdio| grafanamcp[grafana-mcp]
|
||||
agent -->|stdio| minifluxmcp[miniflux-mcp]
|
||||
|
||||
giteamcp -->|authn.Ensure| wallet[(OS keyring<br/>wallet: one TokenSet per service)]
|
||||
grafanamcp -->|authn.Ensure| wallet
|
||||
minifluxmcp -->|authn.Ensure| wallet
|
||||
sh -->|status / logout| wallet
|
||||
|
||||
wallet -->|OAuth PKCE flow on miss<br/>refresh on stale<br/>flock-serialised| authentik[Authentik<br/>gitea provider · grafana provider ·<br/>miniflux provider · …]
|
||||
```
|
||||
|
||||
Each MCP authenticates against the Authentik provider for its own
|
||||
service, lazily, the first time it runs. The wallet caches the
|
||||
result; subsequent runs are silent until the refresh window opens.
|
||||
There is no master session.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Lives at | Owns |
|
||||
|---|---|---|
|
||||
| `sherlock` (CLI) | `cmd/sherlock/` | The only binary the operator runs. `status`, `logout [<service>]`, `run`, agent-name aliases (`copilot`, `claude`, …). At spawn: looks up the agent in `internal/agent/`, renders the per-session MCP config, `exec`s the agent. **There is no `sherlock login`** — see [auth-model.md](auth-model.md). |
|
||||
| `internal/agent/` | — | One Go file per supported CLI (`copilot.go`, `claude.go`, …), each registering itself via `init()`. Shared exec/env helpers live alongside. See [agents.md](agents.md). |
|
||||
| `internal/authn/` | — | OAuth/OIDC primitives + the high-level `Ensure(ctx, store, service, cfg, opts)` that every MCP calls at startup. See [auth-model.md](auth-model.md). |
|
||||
| `internal/keyring/` | — | OS keyring wallet, service-keyed: `Get` / `Set` / `Clear` / `List` per service. `Open()` is the only constructor; it probes the keyring and fails fast. See [storage.md](storage.md). |
|
||||
| `internal/mcp/` | — | Per-format MCP-config renderers (VS Code shape for Copilot, `.mcp.json` shape for Claude Code). |
|
||||
| `internal/xdg/` | — | Resolves `$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` and the refresh-lock path. |
|
||||
| `cmd/gitea-mcp/` (Phase 2) | — | First per-service MCP. Reads service config from `~/.config/sherlock/services.d/gitea.toml`, calls `authn.Ensure(store, "gitea", cfg, opts)` at startup, then serves MCP requests. |
|
||||
| `cmd/gssh-mcp/` (Phase 3) | — | Thin HTTP+WS client to the existing gssh server. No local certs. See [gssh-integration.md](gssh-integration.md). |
|
||||
| `cmd/grafana-mcp/` | — | Imports upstream `mcp-grafana` as a Go package and serves its read-only tools in-process; injects a sherlock-renewed OAuth bearer per request. Requires Grafana `[auth.jwt]`. See [grafana-mcp.md](grafana-mcp.md). |
|
||||
| `cmd/sherlock-mcp/` (Phase 4) | — | The only **interactive** MCP. Exposes `auth.list_sessions()`, `auth.revoke(service)`, etc., so the agent can query/manage the wallet through tool calls. |
|
||||
|
||||
## Why there is no daemon, and no `sherlock login`
|
||||
|
||||
The Phase 1 design had a separate `sherlock-broker` daemon (forked-child, UDS RPC, PID-file flock, idle timer). It was removed in the post-Phase-1 refactor — Decisions #9 (JSON-over-newline RPC) and #10 (forked-child broker) are both superseded. Then `sherlock login` itself was removed in a follow-up refactor, because preemptive authentication never matched the actual topology.
|
||||
|
||||
Reasoning:
|
||||
|
||||
- **No daemon needed.** The OS keyring is already the single source of truth across processes. Cross-process refresh races are solved by an exclusive `flock()` on `$XDG_RUNTIME_DIR/sherlock.refresh.lock` with the canonical "take lock → re-read → maybe refresh" pattern in `internal/authn/refresh.go`. Concurrent *fresh logins* are serialised by a sibling `flock()` on `$XDG_RUNTIME_DIR/sherlock.login.lock` (`internal/authn/loginlock.go`) so they don't collide on the fixed loopback port.
|
||||
- **No master session.** Caddy + each downstream service have their own Authentik OAuth providers with their own audiences, scopes, and consent screens. A single master token cannot satisfy all of them. Per-service tokens are the right granularity, and per-service tokens are best fetched on demand by the MCP that needs them.
|
||||
- **No `sherlock login` step.** Pre-authenticating to N services up-front for the case where the user only ends up using one of them is wasted browser flows. Lazy-on-first-use is the right default; the wallet caches the rest.
|
||||
|
||||
What we lose: a small amount of latency overhead per refresh (libsecret D-Bus round-trip ~1ms) and the ability to handle a mid-session OAuth pop-up without a user click. What we gain: no daemon lifecycle, no PID files, no IPC protocol, no idle timers, no preemptive-login state machine, and "is sherlock-broker still running" stops being a debugging path.
|
||||
| Area | Role |
|
||||
| --------------------------------------------- | --------------------------------------------------------- |
|
||||
| `cmd/sherlock/` | CLI, wallet commands, agent dispatch, update entry point. |
|
||||
| `cmd/*-mcp/` | Service MCP binaries. Tool details live with each MCP. |
|
||||
| `internal/agent/` | Registered agent profiles and spawn helpers. |
|
||||
| `internal/mcp/` | MCP config rendering. |
|
||||
| `internal/config/` | TOML loading and service/provider resolution. |
|
||||
| `internal/authn/` | OAuth/PKCE login, refresh, token sources, locks. |
|
||||
| `internal/keyring/` | OS keyring wallet. |
|
||||
| `internal/installer/`, `internal/selfupdate/` | Install and update logic. |
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Sherlock does **not** federate identities — that's Authentik's job.
|
||||
- Sherlock does **not** issue SSH certificates — gssh already does that internally when it authenticates a WebSocket session. See [gssh-integration.md](gssh-integration.md).
|
||||
- Sherlock does **not** know what tools an MCP exposes — that's the MCP's job. Sherlock only ensures the MCP has the credential it needs.
|
||||
Sherlock does not federate identities, run a background broker, issue SSH
|
||||
credentials, enforce service policy, or define MCP tool behavior. It gets the
|
||||
right credential to the right local MCP process.
|
||||
|
||||
+25
-116
@@ -1,128 +1,37 @@
|
||||
# Auth model
|
||||
|
||||
How sherlock obtains and distributes credentials.
|
||||
Sherlock is a service-keyed OAuth wallet. There is no `sherlock login` and no
|
||||
master session: each MCP authenticates for the service it calls.
|
||||
|
||||
## Mental model: wallet of OAuth sessions
|
||||
## Lifecycle
|
||||
|
||||
Sherlock is a wallet. Each entry is one OAuth session against one upstream
|
||||
service's Authentik provider (gitea, grafana, miniflux, invidious, …). There is
|
||||
**no master Authentik session**; every entry is service-keyed.
|
||||
For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks
|
||||
`internal/authn` for a token on first use:
|
||||
|
||||
This matches the homelab topology: caddy reverse-proxies the public endpoints
|
||||
and enforces edge OIDC for browsers; each downstream service *also* has its own
|
||||
Authentik OAuth client used to bind a service-side account to the operator's
|
||||
Authentik identity. An MCP that wants to call `gitea.x` needs a token issued by
|
||||
gitea's specific Authentik provider — caddy + gitea both verify it. The same
|
||||
goes for every other service. One master token cannot satisfy all of them.
|
||||
1. Fresh wallet entry: return it.
|
||||
2. Stale entry with refresh token: refresh under
|
||||
`$XDG_RUNTIME_DIR/sherlock.refresh.lock`, persist, return.
|
||||
3. Missing or unrecoverable entry: run a PKCE browser flow on `127.0.0.1:6990`,
|
||||
serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return.
|
||||
|
||||
## Authentication is lazy and per-MCP
|
||||
Long-running MCPs use `TokenSource` and `TokenHolder` so requests always read
|
||||
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.
|
||||
|
||||
`sherlock login` does not exist. The operator does not preemptively
|
||||
authenticate. Each MCP at startup calls
|
||||
## Service identity
|
||||
|
||||
```go
|
||||
ts, err := authn.Ensure(ctx, store, "gitea", cfg, opts)
|
||||
```
|
||||
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.
|
||||
|
||||
`Ensure` (in `internal/authn`) handles every case:
|
||||
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.
|
||||
|
||||
1. Wallet has fresh tokens for `gitea` → return them.
|
||||
2. Wallet has stale tokens with a usable refresh token → refresh under a
|
||||
cross-process flock, persist, return.
|
||||
3. Wallet is empty (or refresh failed) → bind the loopback listener on
|
||||
`127.0.0.1:6990`, run a fresh OAuth PKCE flow against the service's Authentik
|
||||
provider (opens a browser via `xdg-open`), persist, return.
|
||||
## User controls
|
||||
|
||||
The first time an operator runs `sherlock copilot` in a fresh environment, the
|
||||
agent spawns its MCPs; each MCP triggers its own browser flow as it needs one.
|
||||
Subsequent runs are silent until the refresh window opens.
|
||||
`sherlock status` lists stored sessions. `sherlock logout` clears all sessions;
|
||||
`sherlock logout <service>` clears one.
|
||||
|
||||
### Concurrent first-use is serialised
|
||||
|
||||
Because the loopback port (`127.0.0.1:6990`) is a single machine-wide resource,
|
||||
two MCPs hitting their first tool call at the same moment would otherwise both
|
||||
try to bind it and one would fail with `EADDRINUSE`. `Ensure` wraps the fresh
|
||||
login in an exclusive flock on `$XDG_RUNTIME_DIR/sherlock.login.lock` (sibling of
|
||||
the refresh lock, but separate so a long, human-in-the-loop login never blocks a
|
||||
quick refresh). The flows queue; after the first one authenticates, the rest
|
||||
reuse the operator's existing Authentik browser session and complete without a
|
||||
second click. Lock acquisition is context-cancellable, so a waiting flow gives up
|
||||
cleanly if its request is cancelled. See `internal/authn/loginlock.go`.
|
||||
|
||||
## Token renewal
|
||||
|
||||
Authentik access tokens are short-lived (the `sherlock-cli` provider issues
|
||||
~5-minute tokens). An MCP that captured a token once at startup would start
|
||||
getting 401s a few minutes into a long agent session. So MCPs do not hold a
|
||||
static token — they hold a renewer.
|
||||
|
||||
`authn.TokenSource` owns the refresh loop; the MCP supplies a **mandatory
|
||||
callback** that the source invokes with every new token:
|
||||
|
||||
```go
|
||||
holder := authn.NewTokenHolder()
|
||||
src := authn.NewTokenSource(store, "grafana", cfg, opts, holder.Set)
|
||||
|
||||
ts, err := src.Start(ctx) // initial OAuth (browser on first use); calls holder.Set
|
||||
go src.Run(ctx) // renews ahead of expiry; calls holder.Set on every rotation
|
||||
```
|
||||
|
||||
- `Start(ctx)` does the initial `Ensure` and pushes the first token to the
|
||||
callback.
|
||||
- `Run(ctx)` sleeps until `RefreshSkew` before the token expires, refreshes
|
||||
under the same cross-process flock as `EnsureFresh`, and — when the access
|
||||
token actually rotates — invokes the callback again. It is refresh-only and
|
||||
never opens a browser; if the refresh token itself has expired it backs off
|
||||
and leaves re-login to the next `sherlock` invocation.
|
||||
- The callback is required (a nil callback panics at construction). The request
|
||||
path never polls: it reads the current bearer from the `TokenHolder` the
|
||||
callback writes (`holder.AccessToken` / `holder.Bearer`), which is lock-free
|
||||
and always reflects the latest renewal.
|
||||
|
||||
This is why every MCP's API client takes a token *getter*, not a token string:
|
||||
`grafana-mcp` reads it from a per-request `BaseTransport`, `gitea-mcp` and
|
||||
`gssh-mcp` from `holder.AccessToken` on each REST call and WebSocket dial.
|
||||
|
||||
## What lives in `services.d/<name>.toml` (Phase 2)
|
||||
|
||||
Each MCP needs an OIDC `Config` (issuer + client ID + scopes) to pass to
|
||||
`Ensure`. The operator-editable source of truth is
|
||||
|
||||
```toml
|
||||
[service]
|
||||
name = "gitea"
|
||||
issuer = "https://id.alexandru.macocian.me/application/o/gitea/"
|
||||
client_id = "Ig8...abc"
|
||||
scopes = ["openid", "profile", "email"]
|
||||
audience = "gitea.alexandru.macocian.me" # informational; aud check is on the IdP side
|
||||
|
||||
[mcp]
|
||||
env_var = "GITEA_TOKEN" # what sherlock injects into the agent env (Phase 2+)
|
||||
```
|
||||
|
||||
Sherlock-the-CLI does not read these in Phase 1 (the CLI has no authentication
|
||||
subcommands). Phase 2 MCPs load their own service's TOML at startup. See
|
||||
[service-registry.md](service-registry.md) for the full schema.
|
||||
|
||||
## Audience binding
|
||||
|
||||
Per the MCP 2025-06-18 spec, downstream tokens MUST be audience-bound. Each
|
||||
Authentik provider issues tokens with `aud` set to its service's canonical name.
|
||||
Caddy and the upstream both verify it. Sherlock never tries to reuse one
|
||||
service's token for another.
|
||||
|
||||
## Scope minimisation
|
||||
|
||||
Default service registrations request read-only scopes. Write scopes require an
|
||||
explicit override in the service's TOML (Phase 2).
|
||||
|
||||
## Out of scope for sherlock
|
||||
|
||||
- Issuing SSH certs (gssh handles this —
|
||||
[gssh-integration.md](gssh-integration.md)).
|
||||
- Multi-user / shared tenancy. Sherlock is per-operator, full stop.
|
||||
- Acting as a Resource Server itself.
|
||||
- RFC 8693 token exchange. Tried during planning; rejected because each
|
||||
service's Authentik provider has its own consent screen and scope set and the
|
||||
exchange story across them is more brittle than just running N separate PKCE
|
||||
flows lazily.
|
||||
Sherlock does not use static service-account tokens, share sessions between
|
||||
operators, or perform RFC 8693 token exchange.
|
||||
|
||||
+27
-72
@@ -1,89 +1,44 @@
|
||||
# Configuration
|
||||
|
||||
Sherlock reads all deployment-specific values — the Authentik
|
||||
issuer/client IDs and each service's base URL — from a single TOML file.
|
||||
There are **no compiled-in defaults**: a missing file, section, or
|
||||
required field is a hard error, so an MCP can never silently target the
|
||||
wrong host.
|
||||
Sherlock reads deployment values from one TOML file. Missing files, sections, or
|
||||
required fields are hard errors.
|
||||
|
||||
A starter file lives at [`config.example.toml`](../config.example.toml).
|
||||
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 this order:
|
||||
Resolved in order:
|
||||
|
||||
1. `$SHERLOCK_CONFIG` (an explicit path; handy for tests / non-standard
|
||||
installs).
|
||||
2. `$XDG_CONFIG_HOME/sherlock/config.toml`.
|
||||
3. `$HOME/.config/sherlock/config.toml`.
|
||||
1. `$SHERLOCK_CONFIG`
|
||||
2. `$XDG_CONFIG_HOME/sherlock/config.toml`
|
||||
3. `$HOME/.config/sherlock/config.toml`
|
||||
|
||||
## Shape
|
||||
|
||||
```toml
|
||||
[providers.sherlock-cli]
|
||||
issuer = "https://id.example.com/application/o/sherlock-cli/"
|
||||
client_id = "…"
|
||||
`[oauth]` is optional. Set `browser = "<executable>"` to open OAuth login URLs
|
||||
with a specific browser, for example `browser = "firefox"`. Leave it unset to
|
||||
use the OS default browser.
|
||||
|
||||
[services.gitea]
|
||||
issuer = "https://gitea.example.com"
|
||||
client_id = "…"
|
||||
base_url = "https://gitea.example.com"
|
||||
`[providers.<name>]` defines a reusable OAuth/OIDC identity: `issuer`,
|
||||
`client_id`, optional `client_secret`.
|
||||
|
||||
[services.grafana]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://grafana.example.com"
|
||||
`[services.<name>]` defines one MCP target. The service name is also the wallet
|
||||
key used by `sherlock status` and `sherlock logout <name>`. Each service
|
||||
requires `base_url` plus either:
|
||||
|
||||
[services.gssh]
|
||||
provider = "sherlock-cli"
|
||||
base_url = "https://terminal.example.com"
|
||||
```
|
||||
- `provider = "<name>"`, resolved from `[providers.<name>]`, or
|
||||
- inline `issuer`, `client_id`, and optional `client_secret`.
|
||||
|
||||
### Providers
|
||||
Mixing `provider` with inline identity fields is rejected.
|
||||
|
||||
A `[providers.<name>]` block is a reusable OAuth/OIDC client
|
||||
(`issuer`, `client_id`, optional `client_secret`). Define it once and
|
||||
share it across every service it fronts. The `sherlock-cli` Authentik
|
||||
provider is shared by `grafana` and `gssh` (and any future
|
||||
Authentik-fronted service); it is a Public/PKCE client, so
|
||||
`client_secret` is omitted and its redirect URI must be whitelisted as
|
||||
`http://127.0.0.1:6990/callback`.
|
||||
## Not configured here
|
||||
|
||||
### Services
|
||||
Tokens live in the OS keyring. OAuth scopes live in MCP code. Agent profiles and
|
||||
the built-in MCP registry live in Go code.
|
||||
|
||||
Each `[services.<name>]` is one MCP's target. `<name>` is both the
|
||||
config key and the wallet key (`sherlock logout <name>`). A service
|
||||
must resolve to a non-empty `issuer`, `client_id`, and `base_url`, by
|
||||
either:
|
||||
|
||||
- **referencing a provider** — `provider = "sherlock-cli"` (Authentik
|
||||
services), or
|
||||
- **carrying an inline identity** — `issuer` + `client_id`
|
||||
(+ `client_secret` if required). Gitea uses this form because it runs
|
||||
its own OAuth2 server rather than authenticating against Authentik.
|
||||
|
||||
Setting both `provider` and an inline `issuer`/`client_id` on the same
|
||||
service is rejected — pick one.
|
||||
|
||||
`base_url` is the API origin the MCP calls (e.g. `/api/v1/...` for
|
||||
Gitea, `/api/...` for Grafana). It is always required.
|
||||
|
||||
## What is *not* in the config
|
||||
|
||||
- **OAuth scopes** — each MCP declares the scopes it needs in code
|
||||
(Gitea pulls a broad read set; Grafana/Gssh ask for
|
||||
`openid profile email`). Scopes are a function of what the MCP does,
|
||||
not of the deployment.
|
||||
- **Secrets / tokens** — the operator's OAuth tokens live in the OS
|
||||
keyring, never in this file. See [storage.md](storage.md).
|
||||
- **The loopback redirect port** (`127.0.0.1:6990`) — fixed in code;
|
||||
see [auth-model.md](auth-model.md).
|
||||
|
||||
## Adding a service
|
||||
|
||||
1. Add a `[services.<name>]` block (provider reference or inline
|
||||
identity, plus `base_url`).
|
||||
2. Point its MCP's `serviceName` constant at `<name>` (it's the config
|
||||
key and the wallet key).
|
||||
|
||||
See [conventions.md](conventions.md) for the broader extensibility
|
||||
model.
|
||||
To add a service, add its config entry and wire or install the matching MCP
|
||||
binary.
|
||||
|
||||
+31
-49
@@ -1,68 +1,50 @@
|
||||
# Conventions
|
||||
|
||||
Rules that govern this repo. Anything that becomes load-bearing belongs here, in `conventions.md`, never in a section appended to another doc.
|
||||
Repository rules that should stay true as the project changes.
|
||||
|
||||
## Repository layout
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/ one subdir per shippable binary; package main only
|
||||
setup/ bootstrap installer entry (go run ./setup); not shipped
|
||||
internal/ every other package; never imported outside this module
|
||||
internal/installer/ the one install/update implementation (shared by setup + `sherlock update`)
|
||||
docs/ one topic per file (see "Docs" below)
|
||||
VERSION release version source of truth (see versioning.md)
|
||||
README.md TOC only
|
||||
```
|
||||
|
||||
No top-level `pkg/` until we have an external consumer.
|
||||
- Shippable binaries live under `cmd/<name>/`.
|
||||
- The bootstrap installer lives under `setup/`; shared install logic lives in
|
||||
`internal/installer/`.
|
||||
- Shared Go code lives under `internal/`. Add no top-level `pkg/` until there is
|
||||
an external consumer.
|
||||
- `README.md` is a short description plus a docs index.
|
||||
- `VERSION` is the release version source of truth.
|
||||
|
||||
## Go
|
||||
|
||||
- Module path: `gitea.alexandru.macocian.me/amacocian/sherlock`.
|
||||
- Target Go toolchain: `go 1.25` (pinned via `go.mod`'s `go` directive; CI installs the matching minor).
|
||||
- One `package main` per `cmd/<binary>/`. No multiple-`main`-files trickery.
|
||||
- Every other package has a top-of-file `Package <name> ...` doc comment (in the leading `.go` source file; we drop standalone `doc.go` files once a real source file exists).
|
||||
- Third-party deps are added with a one-line justification. The current set:
|
||||
- `github.com/zalando/go-keyring` — OS keyring for token persistence.
|
||||
- `github.com/BurntSushi/toml` — parse the operator config (`config.toml`).
|
||||
- `github.com/coreos/go-oidc/v3` — OIDC discovery + ID-token verification against Authentik.
|
||||
- `golang.org/x/oauth2` — auth-code + PKCE + refresh against Authentik's token endpoint.
|
||||
- `golang.org/x/mod` — `semver` for version comparison in self-update.
|
||||
- `github.com/grafana/mcp-grafana` + `github.com/mark3labs/mcp-go` — upstream Grafana MCP tool set, served in-process by `cmd/grafana-mcp`.
|
||||
- `github.com/modelcontextprotocol/go-sdk` — MCP server SDK for the gitea/gssh MCPs.
|
||||
- `github.com/coder/websocket` — gssh exec WebSocket client.
|
||||
- Target Go version: the `go` directive in `go.mod`.
|
||||
- One `package main` per `cmd/<binary>/`.
|
||||
- Non-main packages have a package doc comment in their leading source file.
|
||||
- New third-party dependencies need a short justification with the change.
|
||||
|
||||
## Docs
|
||||
|
||||
- `README.md` is **TOC only**. One-line description + a bulleted index of `docs/*.md`.
|
||||
- **One topic per file under `docs/`.** Never append a new section to an existing doc to cover a new concern; create `docs/<new-topic>.md` and link it from `README.md`.
|
||||
- Cross-link aggressively: every doc should link to the other docs whose concerns it touches.
|
||||
- Mermaid diagrams over images or ASCII art. Diagrams live next to the prose that explains them.
|
||||
- Keep docs short and functional.
|
||||
- Prefer links to source files over copied API/tool examples.
|
||||
- Do not add layout diagrams. Use prose for relationships and source links for
|
||||
details.
|
||||
- One topic per `docs/*.md` file; link from `README.md`.
|
||||
|
||||
## Naming
|
||||
|
||||
- Binaries are kebab-case (`gitea-mcp`, `gssh-mcp`).
|
||||
- Built-in agent profiles match the agent's canonical CLI name (`copilot`, `claude`, `aider`).
|
||||
- Service registry files match the service's canonical name (`gitea.toml`, `grafana.toml`).
|
||||
- Env vars are `<SERVICE>_TOKEN` (e.g. `GITEA_TOKEN`, `GSSH_TOKEN`).
|
||||
- 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`,
|
||||
`searxng`.
|
||||
|
||||
## Extensibility invariants
|
||||
## Commits and CI
|
||||
|
||||
- **Agent extensibility:** adding a new agent CLI is a Go file under `internal/agent/` registering itself via `init()`. See [agents.md](agents.md). The TOML-overlay design was tried and dropped — per-CLI quirks (auth subcommands, flag schemas, MCP config shapes) deserve a real code home.
|
||||
- **Service extensibility:** adding a new downstream service is a TOML drop-in under `~/.config/sherlock/services.d/` + (optionally) a new `cmd/<service>-mcp/` binary. Sherlock's own code does not learn about individual services.
|
||||
|
||||
## Commits
|
||||
|
||||
- Conventional-ish: `area: short imperative` (e.g. `authn: persist client_id in TokenSet`, `docs: add gssh-integration`).
|
||||
- One logical change per commit. CI must pass on every commit on `main`.
|
||||
|
||||
## CI
|
||||
|
||||
- `.gitea/workflows/release.yaml` is the single pipeline. On every push + PR it runs `gofmt`, `go vet`, `errcheck`, `staticcheck`, `go test -race`, and `go build`.
|
||||
- On **push to `main` only**, and **only after every gate above passes**, a final step reads the root `VERSION` file and pushes the tag `vVERSION` if it doesn't already exist. A tag is never created on a red build. Cutting a release is a one-line edit to `VERSION`. See [versioning.md](versioning.md).
|
||||
- Sherlock is operator-installed via `install.sh` (which clones + `go install`s) and self-updates via `sherlock update`. No host-deploy pipeline.
|
||||
- Commit style: `area: short imperative`.
|
||||
- Keep one logical change per commit.
|
||||
- `.gitea/workflows/release.yaml` runs formatting, static analysis, race tests,
|
||||
and build.
|
||||
- Tags are created from `VERSION` only after the main-branch release workflow
|
||||
passes.
|
||||
|
||||
## Security
|
||||
|
||||
- No secrets in this repo, ever. Not in tests, not in fixtures, not in comments.
|
||||
- The operator's Authentik tokens and (Phase 2+) per-service tokens live in the OS keyring. See [storage.md](storage.md).
|
||||
No secrets in the repository. OAuth tokens live in the OS keyring; config files
|
||||
contain deployment metadata only.
|
||||
|
||||
+21
-255
@@ -1,264 +1,30 @@
|
||||
# gitea-mcp
|
||||
|
||||
The first sherlock MCP. Wraps a small slice of the Gitea REST API
|
||||
(`whoami`, `list_repos`) using a Gitea-issued OAuth2 bearer token.
|
||||
Stdio MCP for Gitea's REST API. It exposes read-only access to user,
|
||||
repository/content, refs, commits, issues, pull requests, releases, wiki,
|
||||
actions, packages, and organisation data. Exact tool names and schemas live in
|
||||
`cmd/gitea-mcp/tools_*.go`.
|
||||
|
||||
## How auth works
|
||||
## Auth
|
||||
|
||||
Gitea has two relevant features that share the word "OAuth":
|
||||
`gitea-mcp` uses Gitea's OAuth2 server with PKCE and stores its session under
|
||||
wallet key `gitea`. Gitea web SSO through Authentik is incidental; API tokens
|
||||
are minted by Gitea.
|
||||
|
||||
1. **Gitea as OAuth *client*** — Gitea logs users into its web UI via
|
||||
Authentik (your homelab SSO). This is how human accounts get
|
||||
provisioned the first time someone visits Gitea.
|
||||
2. **Gitea as OAuth *server*** — Gitea is itself an OAuth 2.0 / OIDC
|
||||
provider that third-party apps can authenticate against. The
|
||||
resulting access token is accepted by Gitea's own REST API.
|
||||
Config is `[services.gitea]` with inline `issuer`, `client_id`, optional
|
||||
`client_secret`, and `base_url`. Scopes are compiled in `cmd/gitea-mcp/main.go`;
|
||||
after scope changes, clear the wallet entry with `sherlock logout gitea`.
|
||||
|
||||
`gitea-mcp` uses (2). The flow does not touch Authentik at all from
|
||||
sherlock's perspective — sherlock OAuths directly against Gitea using
|
||||
PKCE, gets a Gitea-minted bearer token, and uses it as
|
||||
`Authorization: Bearer <token>` on `/api/v1/...` calls. The fact that
|
||||
Gitea behind the scenes might bounce you through Authentik for SSO is
|
||||
invisible to sherlock.
|
||||
## Operation
|
||||
|
||||
This is deliberate. Going through Authentik would require Gitea to
|
||||
trust Authentik-issued bearer tokens at its API layer, which Gitea
|
||||
doesn't do out of the box. Going through Gitea's own OAuth2 server is
|
||||
the documented, supported path.
|
||||
When `gitea-mcp` is installed, `sherlock <agent>` includes it in the generated
|
||||
MCP config. The first tool call authenticates lazily; later calls use refreshed
|
||||
keyring tokens. `gitea-mcp --probe` verifies auth and one API call without an
|
||||
agent.
|
||||
|
||||
### Known Gitea scope limitation
|
||||
## Known limitation
|
||||
|
||||
Gitea's OAuth2 server does **not** honour the full scope list sherlock
|
||||
requests. We send `openid profile email read:user read:repository
|
||||
read:issue read:organization read:package`, but Gitea silently grants
|
||||
only `read:repository read:user` regardless. Verified end-to-end
|
||||
2026-05-28 against `gitea.alexandru.macocian.me`: the token's `scope`
|
||||
attribute on `/login/oauth/access_token` comes back narrowed, and any
|
||||
subsequent call into an ungranted area returns:
|
||||
|
||||
```
|
||||
HTTP 403: token does not have at least one of required scope(s),
|
||||
required=[read:issue|read:organization|read:package],
|
||||
token scope=read:repository,read:user
|
||||
```
|
||||
|
||||
Affected tools (every one returns 403 with this token, regardless of
|
||||
how many times you `sherlock logout gitea` and re-auth):
|
||||
|
||||
- **Issues / PRs:** `list_issues`, `get_issue`, `list_issue_comments`
|
||||
(PRs themselves go through `read:repository` and *do* work).
|
||||
- **Organisations:** `list_my_orgs`, `get_org`, `list_org_repos`,
|
||||
`list_org_members`, `list_org_teams`, `search_org_teams`,
|
||||
`list_org_activity_feed`, `list_org_workflow_runs`,
|
||||
`list_org_workflow_jobs`, `list_org_runners`, `get_org_runner`.
|
||||
- **Packages:** `list_packages`.
|
||||
|
||||
This is a Gitea-side issue (the OAuth2 server doesn't surface those
|
||||
scopes on the consent screen, so the user can't grant them even if
|
||||
they wanted to). Workarounds we may consider later:
|
||||
|
||||
1. Use a long-lived PAT scoped explicitly to issue/org/package read
|
||||
instead of an OAuth token for these tool families (would require a
|
||||
second wallet entry and a `--use-pat` knob on the MCP).
|
||||
2. Patch / upgrade Gitea once upstream wires these scopes into the
|
||||
OAuth2 consent flow.
|
||||
3. Drop the affected tools from the surface and document the gap.
|
||||
|
||||
Until one of those lands, those tools stay registered but will 403 at
|
||||
call time. The MCP itself does not refuse to start.
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. Sign in to https://gitea.alexandru.macocian.me as the operator.
|
||||
2. **Settings → Applications → Manage OAuth2 Applications → New
|
||||
Application**:
|
||||
- **Application Name:** `sherlock`
|
||||
- **Redirect URIs:** `http://127.0.0.1:6990/callback`
|
||||
- **Confidential Client:** ⬜ **UNCHECK** — sherlock uses PKCE,
|
||||
not a client secret. Leaving this checked makes Gitea demand a
|
||||
`client_secret` on the token request, which sherlock never sends.
|
||||
3. Click **Create Application**.
|
||||
4. Copy the **Client ID** Gitea displays.
|
||||
|
||||
## Build & install
|
||||
|
||||
For Charlie (default — Client ID embedded in the binary):
|
||||
|
||||
```bash
|
||||
cd ~/Dev/charlie/sherlock
|
||||
go install ./cmd/gitea-mcp
|
||||
```
|
||||
|
||||
That's it. No `-ldflags`, no flags at all. The Charlie Gitea OAuth2
|
||||
app's Client ID lives in `cmd/gitea-mcp/gitea_clientid_charlie.go`
|
||||
and is baked in unless you build with `-tags noembed`.
|
||||
|
||||
For other deployments:
|
||||
|
||||
```bash
|
||||
go build -tags noembed \
|
||||
-ldflags "-X main.giteaIssuer=https://gitea.example.com \
|
||||
-X main.giteaClientID=<CLIENT_ID> \
|
||||
-X main.giteaBaseURL=https://gitea.example.com" \
|
||||
./cmd/gitea-mcp
|
||||
```
|
||||
|
||||
Modern Gitea (≥ 1.16-ish) accepts PKCE-alone for OAuth2 applications
|
||||
that have the "Confidential Client" checkbox unchecked, so no client
|
||||
secret is needed even though Gitea generates one in the UI.
|
||||
|
||||
### Knobs
|
||||
|
||||
| Variable / `-X main.…` | Charlie default |
|
||||
|------------------------|-----------------------------------------------|
|
||||
| `giteaIssuer` | `https://gitea.alexandru.macocian.me` |
|
||||
| `giteaBaseURL` | `https://gitea.alexandru.macocian.me` |
|
||||
| `giteaClientID` | embedded; see `gitea_clientid_charlie.go` |
|
||||
| `giteaClientSecret` | `""` (only needed for older / confidential Gitea apps) |
|
||||
| `Version` | `0.0.0-dev` |
|
||||
|
||||
When pointing at a different Gitea deployment, override `giteaIssuer`,
|
||||
`giteaBaseURL`, and `giteaClientID` together (with `-tags noembed`).
|
||||
Sherlock treats the wallet entry as service `gitea` regardless of
|
||||
which deployment the token came from, so don't mix.
|
||||
|
||||
> If `gitea-mcp --probe` ever hits `invalid_client` or similar after
|
||||
> you click Authorize in the browser, your Gitea is enforcing client
|
||||
> auth on token exchange. Rebuild with
|
||||
> `-X main.giteaClientSecret=<the secret>` to include it. The secret
|
||||
> is baked into the local binary, never written to the source tree.
|
||||
|
||||
## Verify (without an agent)
|
||||
|
||||
```bash
|
||||
gitea-mcp --probe
|
||||
```
|
||||
|
||||
First run: opens a browser, you click through Gitea's "Authorize
|
||||
sherlock" page (which itself goes through Authentik SSO if you're not
|
||||
already signed in), browser shows "Logged in. You may close this
|
||||
tab.", terminal prints:
|
||||
|
||||
```
|
||||
OK: logged in to https://gitea.alexandru.macocian.me as <login> <<email>>
|
||||
```
|
||||
|
||||
Subsequent runs are silent until the refresh window opens.
|
||||
|
||||
To force a re-login: `sherlock logout gitea`.
|
||||
|
||||
## Use with an agent
|
||||
|
||||
`sherlock copilot` (or `sherlock claude`) automatically renders an MCP
|
||||
config that lists `gitea-mcp`. Copilot spawns it as a stdio
|
||||
subprocess; the first call into it triggers the OAuth flow described
|
||||
above (browser pops while you're in the middle of a chat — accept
|
||||
once, never again).
|
||||
|
||||
Tools exposed (all read-only; write tools land in a follow-up):
|
||||
|
||||
### User
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `whoami` | Authenticated Gitea user. |
|
||||
|
||||
### Repos & content
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_repos` | Search/list repos accessible to the user. |
|
||||
| `get_file` | Read file contents (≤ 256 KiB, truncated tail). |
|
||||
| `list_dir` | List one directory level at a given ref. |
|
||||
| `get_tree` | Recursive listing of files/dirs (≤ 2000 entries) at a given ref. |
|
||||
| `file_history` | Commits touching a specific file. |
|
||||
| `list_branches` | Branches of a repo. |
|
||||
| `get_branch` | One branch's details + tip SHA. |
|
||||
| `list_tags` | Tags of a repo. |
|
||||
| `get_tag` | One tag's details. |
|
||||
|
||||
### Commits
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_commits` | Repo commit history with date / SHA filters. |
|
||||
| `get_commit` | Single commit by SHA (full message, parents). |
|
||||
| `get_commit_status` | Combined CI / status check state for a ref. |
|
||||
|
||||
### Issues
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|-----------------------------------------------|
|
||||
| `list_issues` | Issues with state/labels/type filters. |
|
||||
| `get_issue` | One issue or PR by index. |
|
||||
| `list_issue_comments` | Comments on a single issue / PR. |
|
||||
|
||||
### Pull requests
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_pulls` | PRs with state/sort/labels filters. |
|
||||
| `get_pull` | Full PR details incl. requested reviewers. |
|
||||
| `list_pull_files` | Files changed by a PR (counts; no diffs). |
|
||||
| `list_pull_reviews` | Reviews on a PR (state, body, reviewer). |
|
||||
|
||||
### Releases
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_releases` | Releases of a repo (drafts & pre-releases opt-in). |
|
||||
| `get_release` | One release by ID, incl. assets. |
|
||||
|
||||
### Actions (CI)
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `list_workflow_runs` | Per-repo pipeline runs. |
|
||||
| `list_workflow_jobs` | Jobs of a specific run. |
|
||||
| `get_job_logs` | Tail (100 KiB) of a job's log output. |
|
||||
| `list_org_workflow_runs` | All runs across an org's repos. |
|
||||
| `list_org_workflow_jobs` | All jobs across an org's runs. |
|
||||
| `list_org_runners` | Self-hosted runners registered to an org. |
|
||||
| `get_org_runner` | Details for a single org runner. |
|
||||
|
||||
### Packages
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_packages` | Packages owned by a user/org (type + name). |
|
||||
|
||||
### Wiki
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| `list_wiki_pages` | Wiki page titles/slugs/last-update. |
|
||||
| `get_wiki_page` | One wiki page content. |
|
||||
|
||||
### Organisations
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `list_my_orgs` | Orgs the authenticated user is a member of.|
|
||||
| `get_org` | One org's details. |
|
||||
| `list_org_repos` | Repos owned by an org. |
|
||||
| `list_org_members` | Members of an org. |
|
||||
| `list_org_teams` | Teams of an org. |
|
||||
| `search_org_teams` | Search teams in an org by name substring. |
|
||||
| `list_org_activity_feed` | Recent activity feed of an org. |
|
||||
|
||||
Write operations and admin-scoped tools (admin runners, admin
|
||||
workflow runs, admin org management) land in a separate
|
||||
`gitea-mcp-admin` MCP later so the per-tool permission surface stays
|
||||
small.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause |
|
||||
|------------------------------------------------------|---------------------------------------------------------------------------------------------|
|
||||
| `gitea-mcp: giteaClientID not configured.` | Built without `-ldflags "-X main.giteaClientID=..."`. See above. |
|
||||
| Browser opens, Gitea says **"Client ID not registered"** | Either the OAuth2 application was never created in Gitea, or the Client ID was mistyped. |
|
||||
| Browser opens, Gitea says **"client_secret required"** | The OAuth2 application has "Confidential Client" checked. Edit it and uncheck. |
|
||||
| `gitea: 401 (token rejected ...)` | Token was revoked or the wallet has stale tokens for a different Gitea deployment. Run `sherlock logout gitea` and retry. |
|
||||
| `bind: address already in use` | A previous OAuth flow (or another MCP) still holds `127.0.0.1:6990`. Wait a few seconds or kill the stuck process. |
|
||||
EOF
|
||||
echo "gitea-mcp doc created"
|
||||
Some Gitea versions narrow OAuth grants to `read:user` and `read:repository`.
|
||||
Tools that require issue, organisation, or package scopes can return 403 even
|
||||
though the MCP starts correctly. That is a Gitea-side scope grant issue, not a
|
||||
sherlock startup failure.
|
||||
|
||||
+17
-128
@@ -1,136 +1,25 @@
|
||||
# grafana-mcp
|
||||
|
||||
Sherlock's Grafana MCP **imports Grafana Labs' upstream
|
||||
[`mcp-grafana`](https://github.com/grafana/mcp-grafana) as a Go
|
||||
package** and serves a read-only subset of its tools in-process. There
|
||||
is no separate `mcp-grafana` binary, no `uvx`, and no `exec`.
|
||||
Read-only Grafana MCP. Sherlock imports Grafana Labs' upstream `mcp-grafana` as
|
||||
a Go package and registers the read-only search, datasource, Prometheus, Loki,
|
||||
alerting, dashboard, folder, navigation, and annotation categories. Exact tool
|
||||
behavior belongs to upstream and `cmd/grafana-mcp/main.go`.
|
||||
|
||||
It authenticates as the operator the same way `gitea-mcp` and
|
||||
`gssh-mcp` do — OAuth + PKCE against Authentik, token stored in the OS
|
||||
keyring — so the agent never sees a Grafana service-account token.
|
||||
## Auth
|
||||
|
||||
## How auth works
|
||||
`grafana-mcp` uses sherlock-managed OAuth with the wallet key `grafana`. It
|
||||
sends a fresh Authentik bearer on every Grafana API request and does not use
|
||||
`GRAFANA_SERVICE_ACCOUNT_TOKEN`, `GRAFANA_API_KEY`, or username/password auth.
|
||||
|
||||
`grafana-mcp` does **not** use `GRAFANA_SERVICE_ACCOUNT_TOKEN`,
|
||||
`GRAFANA_API_KEY`, or Grafana username/password auth.
|
||||
Grafana must accept external JWT bearers on its API through `[auth.jwt]`.
|
||||
Generic OAuth is only browser SSO and is not enough for `/api/*` bearer
|
||||
validation.
|
||||
|
||||
At startup it calls `authn.NewTokenSource(...).Start(ctx)` to obtain an
|
||||
Authentik access token (browser flow on first use). Every Grafana API
|
||||
request then carries that token as `Authorization: Bearer <jwt>`,
|
||||
injected by a custom `GrafanaConfig.BaseTransport`. The token is kept
|
||||
fresh by the sherlock `TokenSource` renewer (see
|
||||
[auth-model.md](auth-model.md#token-renewal)) — Authentik access tokens
|
||||
live only ~5 minutes, so a captured-once token would 401 mid-session.
|
||||
## Operation
|
||||
|
||||
Only read-only tool categories are registered (search, datasource,
|
||||
prometheus, loki, alerting, dashboard, folder, navigation, annotations);
|
||||
upstream write tools are disabled.
|
||||
Config is `[services.grafana]`, usually pointing at the shared `sherlock-cli`
|
||||
provider plus `base_url`. The first tool call authenticates lazily;
|
||||
`grafana-mcp --probe` verifies auth and `GET /api/user`.
|
||||
`sherlock logout grafana` clears the session.
|
||||
|
||||
## Why generic OAuth is not enough (and what is)
|
||||
|
||||
Grafana already federates with Authentik via
|
||||
`GF_AUTH_GENERIC_OAUTH_*`. That is **not** the same mechanism and does
|
||||
**not** make this MCP work:
|
||||
|
||||
| | `GF_AUTH_GENERIC_OAUTH_*` | `grafana-mcp` → API |
|
||||
|---|---|---|
|
||||
| OAuth client | **Grafana itself** | **sherlock** (`sherlock-cli` provider) |
|
||||
| Purpose | interactive browser login | programmatic API call |
|
||||
| Credential | a Grafana **session cookie** | an Authentik **JWT bearer** |
|
||||
| Grafana's job | requests the token, drops it for a cookie | **validate a token it never issued** |
|
||||
|
||||
Generic OAuth logs humans into the UI; it never accepts an externally
|
||||
minted bearer on `/api/*`. Presenting our bearer there makes Grafana
|
||||
treat it as a service-account token and return `Invalid API key`.
|
||||
|
||||
The mechanism that makes Grafana **accept** the Authentik JWT on its
|
||||
API is a separate integration: **`[auth.jwt]`**. It must be enabled on
|
||||
the Grafana deployment (it is missing from the `Charlie/victoriametrics`
|
||||
stack today). `auth.jwt` and `generic_oauth` coexist fine — UI users
|
||||
keep using SSO; sherlock uses JWT on the API.
|
||||
|
||||
## Required Grafana server config
|
||||
|
||||
Add to the `grafana` service environment in
|
||||
`Charlie/victoriametrics/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
GF_AUTH_JWT_ENABLED: "true"
|
||||
GF_AUTH_JWT_HEADER_NAME: "Authorization" # Grafana strips the "Bearer " prefix
|
||||
GF_AUTH_JWT_USERNAME_CLAIM: "preferred_username" # matches the generic_oauth login attr → same user
|
||||
GF_AUTH_JWT_EMAIL_CLAIM: "email"
|
||||
GF_AUTH_JWT_JWK_SET_URL: "https://id.alexandru.macocian.me/application/o/sherlock-cli/jwks/"
|
||||
GF_AUTH_JWT_AUTO_SIGN_UP: "true"
|
||||
GF_AUTH_JWT_ROLE_ATTRIBUTE_PATH: "contains(groups, 'admins') && 'Admin' || 'Viewer'"
|
||||
GF_AUTH_JWT_ROLE_ATTRIBUTE_STRICT: "true"
|
||||
# All Authentik providers share one signing key, so pin the issuer
|
||||
# to sherlock-cli — otherwise any Authentik JWT would authenticate.
|
||||
GF_AUTH_JWT_EXPECT_CLAIMS: '{"iss":"https://id.alexandru.macocian.me/application/o/sherlock-cli/"}'
|
||||
```
|
||||
|
||||
The claim names above are verified against an actual `sherlock-cli`
|
||||
access token, which is a full RS256 JWT carrying: `iss`, `aud`,
|
||||
`preferred_username` (= the operator's username, same value as `sub`),
|
||||
`email`, `name`, and `groups` (including `admins`). Using
|
||||
`preferred_username` aligns with `GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH`,
|
||||
so JWT auth maps to the **same** Grafana account as browser SSO.
|
||||
|
||||
Grafana must be able to reach `GF_AUTH_JWT_JWK_SET_URL` from inside its
|
||||
container.
|
||||
|
||||
## Charlie defaults
|
||||
|
||||
| Variable / `-X main.…` | Charlie default |
|
||||
|------------------------|-----------------|
|
||||
| `grafanaIssuer` | `https://id.alexandru.macocian.me/application/o/sherlock-cli/` |
|
||||
| `grafanaBaseURL` | `https://grafana.alexandru.macocian.me` |
|
||||
| `grafanaClientID` | same Public `sherlock-cli` client as `gssh-mcp` |
|
||||
| `grafanaClientSecret` | `""` (Public, PKCE-only) |
|
||||
| `Version` | `0.0.0-dev` |
|
||||
|
||||
## Build & install
|
||||
|
||||
```bash
|
||||
cd ~/Dev/charlie/sherlock
|
||||
go install ./cmd/grafana-mcp
|
||||
```
|
||||
|
||||
That's it — the upstream tool set is compiled in. For other
|
||||
deployments:
|
||||
|
||||
```bash
|
||||
go build -tags noembed \
|
||||
-ldflags "-X main.grafanaIssuer=https://id.example/application/o/sherlock-cli/ \
|
||||
-X main.grafanaClientID=<CLIENT_ID> \
|
||||
-X main.grafanaBaseURL=https://grafana.example" \
|
||||
./cmd/grafana-mcp
|
||||
```
|
||||
|
||||
## Verify without an agent
|
||||
|
||||
```bash
|
||||
grafana-mcp --probe
|
||||
```
|
||||
|
||||
Expected once `[auth.jwt]` is configured:
|
||||
|
||||
```text
|
||||
OK: logged in to https://grafana.alexandru.macocian.me as <login> <<email>>
|
||||
```
|
||||
|
||||
A 401/403 (e.g. `Invalid API key`) means OAuth itself succeeded but
|
||||
Grafana is not yet validating the Authentik JWT for API access — apply
|
||||
the server config above. Force a re-login with `sherlock logout grafana`.
|
||||
|
||||
## Use with an agent
|
||||
|
||||
`sherlock copilot` / `sherlock claude` automatically render an MCP
|
||||
config listing `grafana-mcp` when the binary is installed. All exposed
|
||||
tools are read-only (subject to Grafana RBAC): dashboard
|
||||
search/inspection, datasource listing/querying (Prometheus, Loki, …),
|
||||
alert-rule and notification reads, annotations, and navigation
|
||||
deeplinks.
|
||||
|
||||
This relies on Grafana-side `[auth.jwt]` being configured (above). On a
|
||||
deployment where that isn't in place, the MCP still starts but every
|
||||
tool call 401s; verify with `grafana-mcp --probe` first.
|
||||
All exposed tools are read-only and still subject to Grafana RBAC.
|
||||
|
||||
+15
-55
@@ -1,61 +1,21 @@
|
||||
# gssh integration
|
||||
|
||||
How `gssh-mcp` will reuse the existing gssh server. Decided in Phase-0 planning; implementation in Phase 3.
|
||||
`gssh-mcp` is a client to the existing Gssh gateway. Sherlock does not open SSH
|
||||
connections, mint certificates, enforce host policy, or duplicate Gssh audit
|
||||
behavior.
|
||||
|
||||
## Why reuse
|
||||
Gssh owns JWT validation, host allow-lists, ephemeral SSH certificate handling,
|
||||
and command execution. Sherlock obtains the operator's OAuth token and passes it
|
||||
to `gssh-mcp`, which calls Gssh over HTTP and WebSocket.
|
||||
|
||||
The gssh server at `gssh.alexandru.macocian.me` already:
|
||||
- Authenticates inbound requests via Authentik JWTs (`HttpContext.User`).
|
||||
- Resolves the per-user host allow-list (`SessionController.GetHosts` → `SshConnectorService.Hosts`).
|
||||
- Establishes SSH sessions internally using the host's CA-signed credentials (the operator never sees an SSH key).
|
||||
- Streams stdin/stdout as a binary WebSocket and accepts text control messages for terminal resize (`SessionSocketRoute`).
|
||||
Current contract used by sherlock:
|
||||
|
||||
That's the entire job of "let an authorized human run a command on a Charlie host". Building a parallel SSH broker inside sherlock would duplicate the CA integration, the host-allow-list logic, and the audit trail — and put another piece of cert-handling code in our blast radius. We do not do that.
|
||||
| Endpoint | Purpose |
|
||||
| --------------------------------- | ----------------------------------------- |
|
||||
| `GET /api/v1/session/hosts` | Host allow-list. |
|
||||
| `POST /api/v1/session/initialize` | Ensure a Gssh session/certificate exists. |
|
||||
| `GET /api/v1/users/me` | Probe/debug identity. |
|
||||
| `WS /api/v1/exec/{host}` | Single-command execution stream. |
|
||||
|
||||
## What `gssh-mcp` actually is
|
||||
|
||||
A thin Go HTTP+WebSocket client to the existing gssh server, wrapped in a stdio MCP. It:
|
||||
|
||||
1. Reads `GSSH_TOKEN` from env at startup (an Authentik JWT for `aud=gssh`, written into the env by sherlock at agent spawn from the operator's stored TokenSet).
|
||||
2. Calls `POST /api/v1/session/initialize` with `Authorization: Bearer <jwt>` to materialise the user's session on the gssh server.
|
||||
3. Caches the list of permitted hosts from `GET /api/v1/session/hosts`.
|
||||
4. Exposes MCP tools:
|
||||
- `ssh.list_hosts()` — returns the cached host list.
|
||||
- `ssh.run(host, command, timeout?)` — opens a WebSocket to the session route for `host`, writes `command + "; echo __SHERLOCK_DONE__$?\n"`, reads until it sees the sentinel, returns `{stdout, exit_code}`.
|
||||
- `ssh.put_file(host, path, content)` (later) — same shell channel, base64-decode + `tee` on the far side, sentinel as above.
|
||||
5. On 401, re-reads the keyring (calling `authn.EnsureFresh`, which `flock`-serialises against concurrent MCP refreshes) and retries once.
|
||||
|
||||
No SSH key handling. No cert minting. No `~/Dev/gssh` shell-out.
|
||||
|
||||
## Endpoints sherlock relies on (gssh contract)
|
||||
|
||||
| Method | Path | Used for |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/v1/session/initialize` | Materialise the user's session before opening the WebSocket. |
|
||||
| `GET` | `/api/v1/session/hosts` | Per-user host allow-list. |
|
||||
| `GET` | `/api/v1/users/me` | Sanity check / debug. |
|
||||
| `WS` | `/<session-socket-route>?hostName=<host>` (exact path defined in gssh's routing) | Bidirectional binary stream = stdin/stdout of the SSH session. Text frames are JSON control messages (e.g. `ResizeMessage`). |
|
||||
|
||||
If a gssh release ever changes one of these, `gssh-mcp` is the only thing in sherlock that needs to follow.
|
||||
|
||||
## Token shape
|
||||
|
||||
- Issuer: `https://id.alexandru.macocian.me/application/o/gssh/`
|
||||
- Audience: `gssh.alexandru.macocian.me`
|
||||
- Service kind in the registry: `oidc-federated`
|
||||
- `exchange.mode = "passthrough"` is safe because gssh already accepts the operator's Authentik ID token (same audience model gssh's web UI uses today). Switch to `rfc8693` only if we ever introduce per-tool scope splitting (e.g. read-only vs write).
|
||||
|
||||
## Completion sentinel — open detail for Phase 3
|
||||
|
||||
The gssh WebSocket is interactive (PTY-style). `gssh-mcp` needs a deterministic way to know a command has finished. Two options:
|
||||
|
||||
1. **Client-side wrap:** `gssh-mcp` sends `printf '%s\n' "<cmd>; echo __SHERLOCK_DONE__$?" | <session-shell>` and parses for the marker.
|
||||
2. **Server-side one-shot mode:** add a small endpoint / route flag on the gssh server that runs a single command and closes the socket with the exit code in the close frame. Cleaner, but it's a gssh change.
|
||||
|
||||
Phase 0 decision: try (1) first because it requires no gssh change; revisit (2) if we hit edge cases (TTY echo, prompt clutter, multi-line stdout truncation).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `gssh-mcp` does NOT speak gssh's WebSocket protocol "directly" by hand-rolling SSH packet framing. It just speaks the documented `/api/v1/session/*` HTTP + WS surface.
|
||||
- `gssh-mcp` does NOT shell out to `/mnt/seagate/Dev/gssh` (which is the C# server source, not a client binary).
|
||||
- `gssh-mcp` does NOT enforce per-host policy locally. The gssh server already does that against JWT claims; duplicating it client-side is a footgun.
|
||||
If that contract changes, `cmd/gssh-mcp/` is the only sherlock area that should
|
||||
follow.
|
||||
|
||||
+18
-147
@@ -1,157 +1,28 @@
|
||||
# gssh-mcp
|
||||
|
||||
sherlock's MCP server for **[Gssh](https://terminal.alexandru.macocian.me)**,
|
||||
the homelab SSH gateway. Lets an agent run a single shell command on
|
||||
an allow-listed host without standing up a PTY, using the operator's
|
||||
own JWT-authenticated session.
|
||||
Stdio MCP for the Gssh gateway. It lets an agent list allowed hosts, initialize
|
||||
the operator's Gssh session, and run one command on one host without exposing
|
||||
local SSH keys.
|
||||
|
||||
## How auth works
|
||||
Exact tool schemas live in `cmd/gssh-mcp/tools_*.go`.
|
||||
|
||||
Two Authentik OIDC providers front the same Gssh deployment:
|
||||
## Auth
|
||||
|
||||
1. **`gssh`** (Confidential) — the long-standing provider that powers
|
||||
Gssh's browser PTY login via server-side OIDC code flow.
|
||||
2. **`sherlock-cli`** (Public, PKCE) — a separate provider sherlock
|
||||
authenticates against from the CLI. No client secret, redirect URI
|
||||
pinned to `http://127.0.0.1:6990/callback`.
|
||||
`gssh-mcp` uses sherlock-managed OAuth with wallet key `gssh`, normally through
|
||||
the shared Authentik `sherlock-cli` provider. Gssh must trust that issuer and
|
||||
audience for bearer auth.
|
||||
|
||||
Gssh's JwtBearer scheme accepts JWTs from either provider via
|
||||
comma-separated `OIDC__ApiAudience` + `OIDC__BearerIssuers`. JWKS
|
||||
verification is anchored on a single discovery URL — all providers in
|
||||
an Authentik tenant share the signing key, so one fetch validates
|
||||
both.
|
||||
Config is `[services.gssh]` with provider or inline OAuth identity plus
|
||||
`base_url`. `sherlock logout gssh` clears the session.
|
||||
|
||||
The `sherlock-cli` provider can be reused by every future
|
||||
Authentik-fronted MCP: each backing service just adds the
|
||||
provider's audience to its own `ValidAudiences` and the provider's
|
||||
issuer to its own `ValidIssuers`. One wallet entry, one OAuth
|
||||
browser pop, N services.
|
||||
## Operation
|
||||
|
||||
## One-time setup
|
||||
When `gssh-mcp` is installed, `sherlock <agent>` includes it in the generated
|
||||
MCP config. The first tool call authenticates lazily. `gssh-mcp --probe`
|
||||
verifies auth against Gssh without an agent.
|
||||
|
||||
### Authentik
|
||||
`run_command` opens a WebSocket to Gssh's exec endpoint for the selected host.
|
||||
The server caps remote command runtime at 60 seconds; the client caps returned
|
||||
output at 256 KiB stdout and 64 KiB stderr.
|
||||
|
||||
Create a second OIDC provider:
|
||||
|
||||
1. **Applications → Providers → Create → OAuth2/OpenID Provider**:
|
||||
- **Client type:** Public
|
||||
- **Redirect URI (Strict):** `http://127.0.0.1:6990/callback`
|
||||
- **Subject mode:** `Based on the User's username` (the default
|
||||
"hashed user ID" mode produces a sub Gssh can't SSH as)
|
||||
- **Scope mappings:** include the standard openid/profile/email
|
||||
**and** a mapping that emits `groups` (Gssh's
|
||||
`OIDC__AllowedRoles` check reads it).
|
||||
2. **Applications → Applications → Create** an app that points at the
|
||||
new provider. Slug = `sherlock-cli` (the slug is the trailing path
|
||||
segment in the issuer URL).
|
||||
|
||||
### Gssh
|
||||
|
||||
Add the new audience and issuer to Gssh's env (alongside the existing
|
||||
gssh-provider values):
|
||||
|
||||
```env
|
||||
OIDC__ApiAudience=<gssh-client-id>,<sherlock-cli-client-id>
|
||||
OIDC__BearerIssuers=https://id.example/application/o/gssh/,https://id.example/application/o/sherlock-cli/
|
||||
```
|
||||
|
||||
## Build & install
|
||||
|
||||
For Charlie (default — Authentik client ID embedded in the binary):
|
||||
|
||||
```bash
|
||||
cd ~/Dev/charlie/sherlock
|
||||
go install ./cmd/gssh-mcp
|
||||
```
|
||||
|
||||
That's it. The Charlie Authentik `sherlock-cli` client ID lives in
|
||||
`cmd/gssh-mcp/gssh_clientid_charlie.go` and is baked in unless you
|
||||
build with `-tags noembed`.
|
||||
|
||||
For other deployments:
|
||||
|
||||
```bash
|
||||
go build -tags noembed \
|
||||
-ldflags "-X main.gsshIssuer=https://id.example/application/o/sherlock-cli/ \
|
||||
-X main.gsshClientID=<CLIENT_ID> \
|
||||
-X main.gsshBaseURL=https://gssh.example" \
|
||||
./cmd/gssh-mcp
|
||||
```
|
||||
|
||||
### Knobs
|
||||
|
||||
| Variable / `-X main.…` | Charlie default |
|
||||
|------------------------|-----------------------------------------------------------------------|
|
||||
| `gsshIssuer` | `https://id.alexandru.macocian.me/application/o/sherlock-cli/` |
|
||||
| `gsshBaseURL` | `https://terminal.alexandru.macocian.me` |
|
||||
| `gsshClientID` | embedded; see `gssh_clientid_charlie.go` |
|
||||
| `gsshClientSecret` | `""` (provider is Public, PKCE-only — no secret involved) |
|
||||
| `Version` | `0.0.0-dev` |
|
||||
|
||||
## Verify (without an agent)
|
||||
|
||||
```bash
|
||||
gssh-mcp --probe
|
||||
```
|
||||
|
||||
First run: opens a browser, you click through Authentik's "Authorize
|
||||
Sherlock-Cli" page, browser shows "Logged in. You may close this
|
||||
tab.", terminal prints:
|
||||
|
||||
```
|
||||
OK: logged in to https://terminal.alexandru.macocian.me as <name> (account=<username>, roles=[...])
|
||||
```
|
||||
|
||||
Subsequent runs are silent until the refresh window opens.
|
||||
|
||||
To force a re-login: `sherlock logout gssh`.
|
||||
|
||||
## Use with an agent
|
||||
|
||||
`sherlock copilot` (or `sherlock claude`) automatically renders an
|
||||
MCP config that lists `gssh-mcp`. Copilot spawns it as a stdio
|
||||
subprocess; the first call into a tool triggers the OAuth flow above.
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------|----------------------------------------------------------------|
|
||||
| `list_hosts` | SSH hosts this operator is allowed to connect to. |
|
||||
| `initialize_session` | Force ephemeral 5-min SSH cert creation (idempotent). |
|
||||
| `run_command` | Run a single shell command on a host; returns stdout / stderr / exit code (or `timed_out` after the server's 60-second cap). |
|
||||
|
||||
`run_command` opens a fresh WebSocket per call to
|
||||
`/api/v1/exec/{host}`, sends a single `Start` datagram carrying the
|
||||
UTF-8 command, then demuxes streamed `Stdout` / `Stderr` datagrams
|
||||
into capped buffers until an `Exit` (carries ASCII exit code) or
|
||||
`Timeout` datagram arrives. Wire protocol lives in
|
||||
`internal/wsdatagram/` and mirrors Gssh's `Models/ShellDatagram.cs`
|
||||
exactly (fixed 2048-byte frame, 1-byte op + 2-byte length + 2045-byte
|
||||
payload).
|
||||
|
||||
### Buffer caps
|
||||
|
||||
| Stream | Cap | When exceeded |
|
||||
|---------|---------|------------------------------|
|
||||
| stdout | 256 KiB | trailing bytes dropped, `stdout_truncated=true` |
|
||||
| stderr | 64 KiB | trailing bytes dropped, `stderr_truncated=true` |
|
||||
|
||||
A `timed_out=true` result means the **remote command** exceeded the
|
||||
server-side 60-second hard cap. The client tool itself has a 90 s
|
||||
deadline so the agent isn't left hanging if the connection or the
|
||||
server stall mid-stream.
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
SHERLOCK_DEBUG_LOG=/tmp/gssh-mcp.log gssh-mcp --probe
|
||||
tail -f /tmp/gssh-mcp.log
|
||||
```
|
||||
|
||||
Every HTTP request, WS dial, Start datagram, and terminal datagram
|
||||
gets one line. Empty `SHERLOCK_DEBUG_LOG` = no logging, no file
|
||||
touched.
|
||||
|
||||
When debugging Gssh-side rejections, look at Gssh's container logs —
|
||||
401s carry the JwtBearer failure reason (audience mismatch, issuer
|
||||
mismatch, expired, …) inline.
|
||||
Set `SHERLOCK_DEBUG_LOG` to capture MCP-side HTTP/WebSocket traces.
|
||||
|
||||
+26
-87
@@ -1,101 +1,40 @@
|
||||
# Installation
|
||||
|
||||
Sherlock installs from a clone of this repo with a single Go command.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://gitea.alexandru.macocian.me/amacocian/sherlock.git
|
||||
cd sherlock
|
||||
go run ./setup
|
||||
```
|
||||
|
||||
(`./install.sh` is kept as a thin convenience wrapper that just runs
|
||||
`go run ./setup` with the same flags.)
|
||||
|
||||
The installer:
|
||||
|
||||
1. **Syncs the source.** Clones (or updates) the sherlock repo under
|
||||
`${XDG_CACHE_HOME:-~/.cache}/sherlock/src` and checks out the latest
|
||||
release tag, so the install is reproducible and shares one checkout
|
||||
with `sherlock update`. (Use `--local` to build from the checkout
|
||||
you ran it from instead — handy for development.)
|
||||
2. **Seeds the config.** Copies [`config.example.toml`](../config.example.toml)
|
||||
to your config path (`~/.config/sherlock/config.toml` by default) — but
|
||||
only if you don't already have one, so re-runs never clobber filled-in
|
||||
values.
|
||||
3. **Opens it in your editor** (`$VISUAL` / `$EDITOR`, falling back to
|
||||
`nano`/`vi`) so you can fill in the Authentik issuer/client IDs and
|
||||
each service's base URL. See [configuration.md](configuration.md) for
|
||||
the schema.
|
||||
4. **Builds and installs** `sherlock` and every MCP binary
|
||||
(`gitea-mcp`, `grafana-mcp`, `gssh-mcp`) with `go install ./cmd/...`,
|
||||
baking in the release version.
|
||||
5. **Installs shell completions** where it can — currently a `fish`
|
||||
completion into `~/.config/fish/completions/sherlock.fish`, but only
|
||||
if you already have a fish config directory (it won't create one for
|
||||
non-fish users).
|
||||
|
||||
The exact same code runs on `sherlock update` — there is one installer,
|
||||
in `internal/installer`, with two entry points (`go run ./setup` for the
|
||||
bootstrap and `sherlock update` for self-update).
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sherlock copilot
|
||||
```
|
||||
Install from a clone with `go run ./setup`. `./install.sh` is a thin wrapper
|
||||
around the same command.
|
||||
|
||||
## Requirements
|
||||
|
||||
- The **Go toolchain** on `PATH` (the installer builds from source).
|
||||
- **git** on `PATH` (to clone the source; not needed with `--local`).
|
||||
- An **OS keyring** / Secret Service provider (the wallet lives there;
|
||||
see [storage.md](storage.md)).
|
||||
- The install dir (`go env GOBIN`, else `$(go env GOPATH)/bin`) on your
|
||||
`PATH` — this is standard Go setup. The installer notes the dir if it
|
||||
isn't already on `PATH`, but adding it is the usual one-time Go step
|
||||
(e.g. `fish_add_path -U ~/go/bin` on fish, or an `export PATH` line in
|
||||
`~/.bashrc`/`~/.zshrc`).
|
||||
- Go toolchain on `PATH`.
|
||||
- `git` on `PATH`, unless using `--local`.
|
||||
- OS keyring/Secret Service available.
|
||||
- The Go install directory on `PATH`.
|
||||
- The agent CLI you plan to launch (`copilot`, `claude`, ...).
|
||||
|
||||
## Flags
|
||||
## What setup does
|
||||
|
||||
Pass to `go run ./setup` (or `./install.sh`):
|
||||
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, 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.
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `-y`, `--yes` | Skip the editor step (config is already filled in). |
|
||||
| `--config-only` | Seed/edit the config but don't build or install. |
|
||||
| `--local` | Build from the current checkout instead of cloning to the cache (development). |
|
||||
| `-h`, `--help` | Show usage. |
|
||||
One implementation backs both bootstrap setup and `sherlock update`.
|
||||
|
||||
## Env overrides
|
||||
## Flags and environment
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `SHERLOCK_CONFIG` | Exact config path to write (else `$XDG_CONFIG_HOME` / `~/.config`). |
|
||||
| `SHERLOCK_UPDATE_REPO_URL` | Clone URL to build from (else the public sherlock repo). |
|
||||
| `VISUAL` / `EDITOR` | Editor to open. |
|
||||
Setup flags: `-y`/`--yes` skips the editor, `--config-only` edits config without
|
||||
installing, `--local` builds the current checkout, `-h`/`--help` prints usage.
|
||||
|
||||
Environment: `SHERLOCK_CONFIG` selects the config path,
|
||||
`SHERLOCK_UPDATE_REPO_URL` selects the source repo, and `VISUAL`/`EDITOR`
|
||||
selects the editor.
|
||||
|
||||
## Updating
|
||||
|
||||
Sherlock updates itself in place (running the same installer):
|
||||
Use `sherlock update` to install a newer release, or `sherlock update --force`
|
||||
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.
|
||||
|
||||
```bash
|
||||
sherlock update # if a newer release exists
|
||||
sherlock update --force # reinstall the latest regardless
|
||||
```
|
||||
|
||||
Release builds also print a one-line hint when a newer version is
|
||||
available. See [versioning.md](versioning.md).
|
||||
|
||||
## Re-running
|
||||
|
||||
`go run ./setup` (or `./install.sh`) is safe to re-run: it edits your
|
||||
existing config in place (never overwriting it) and re-installs the
|
||||
binaries. Use `-y` to skip the editor when you only want to rebuild, or
|
||||
`--config-only` to tweak the config without reinstalling.
|
||||
|
||||
To point sherlock at a different deployment, edit the values in your
|
||||
`config.toml` — there are no compiled-in defaults, so nothing is baked
|
||||
into the binaries. See [configuration.md](configuration.md).
|
||||
Re-running setup is safe: it preserves config and reinstalls binaries.
|
||||
|
||||
@@ -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.
|
||||
+21
-81
@@ -1,93 +1,33 @@
|
||||
# Storage
|
||||
|
||||
How sherlock persists secrets and runtime state.
|
||||
Sherlock stores credentials only in the OS keyring through `internal/keyring`.
|
||||
There are no plaintext token files and no sherlock daemon state.
|
||||
|
||||
## Decisions
|
||||
## Wallet
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Token storage | OS keyring via [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring). No on-disk credential files, no `age` blobs, no plaintext. |
|
||||
| Wallet shape | One `TokenSet` per service (`gitea`, `grafana`, `miniflux`, …). Tracked via a sidecar `services-index` entry so `Store.List()` works on every backend without OS-specific search APIs. |
|
||||
| Pre-flight | The only constructor is `keyring.Open()`, which probes the keyring before returning a Store. There is no probe-less escape hatch. A missing/locked keyring returns `*UnavailableError` (with a populated `Hint` field) and exits 3. |
|
||||
| Keyring service ns | `sherlock` for all real entries; `sherlock-preflight` for the probe sentinel. Per-service tokens are accounts of the form `service:<name>`; the index is account `services-index`. |
|
||||
| Runtime files | `$XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json` (0600), `$XDG_RUNTIME_DIR/sherlock.refresh.lock` (0600, flock anchor for cross-process refreshes). No socket, no PID file, no daemon log. |
|
||||
| Config files | `$XDG_CONFIG_HOME/sherlock/services.d/*.toml` (operator-registered services, Phase 2+). Agent integrations are compiled in — see [agents.md](agents.md). |
|
||||
Each service has one wallet entry under keyring service `sherlock` and account
|
||||
`service:<name>`. A `services-index` side entry makes `sherlock status` portable
|
||||
across keyring backends.
|
||||
|
||||
## TokenSet
|
||||
Stored data includes the service tokens, expiry times, OAuth
|
||||
issuer/client/scopes needed for refresh, and user identity metadata.
|
||||
|
||||
What sherlock stores per service entry:
|
||||
## Pre-flight
|
||||
|
||||
```go
|
||||
type TokenSet struct {
|
||||
IDToken string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDExpiresAt time.Time
|
||||
RefreshExpAt time.Time
|
||||
Issuer string // for refresh: full OIDC issuer URL
|
||||
ClientID string // for refresh: OAuth client ID
|
||||
Scopes []string // for refresh: scopes to re-request
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
}
|
||||
```
|
||||
`keyring.Open()` probes the OS keyring before returning a store. Missing or
|
||||
locked keyrings return `*keyring.UnavailableError` with a remediation hint;
|
||||
CLI/MCP callers exit with keyring failure rather than silently falling back.
|
||||
|
||||
Serialized as a single JSON blob per service entry — the keyring
|
||||
exposes one secret per `(service, account)` pair and we don't want to
|
||||
round-trip multiple secrets per session.
|
||||
## Runtime files
|
||||
|
||||
`Issuer` + `ClientID` + `Scopes` are persisted alongside the tokens so
|
||||
refresh and re-login work from any process, with no dependency on the
|
||||
shell environment that originally created the entry.
|
||||
Runtime files live under `$XDG_RUNTIME_DIR/sherlock/` or the sibling runtime
|
||||
directory:
|
||||
|
||||
## Wallet API
|
||||
- `<agent>.mcp.json`: generated MCP config, mode 0600.
|
||||
- `sherlock.refresh.lock`: cross-process token refresh lock.
|
||||
- `sherlock.login.lock`: cross-process first-login lock.
|
||||
|
||||
`keyring.Store` is service-keyed:
|
||||
## Platform note
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
Get(service string) (TokenSet, error) // ErrNoTokens if missing
|
||||
Set(service string, ts TokenSet) error // also updates the index
|
||||
Clear(service string) error // also updates the index
|
||||
List() ([]string, error) // names sorted
|
||||
}
|
||||
```
|
||||
|
||||
`Get` / `Set` / `Clear` go straight to the OS keyring under
|
||||
`(service="sherlock", account="service:<name>")`. `List` reads the
|
||||
sidecar `services-index` entry; `Set`/`Clear` keep it in sync.
|
||||
|
||||
## Pre-flight semantics
|
||||
|
||||
`keyring.Open()`:
|
||||
|
||||
1. Probes the keyring: writes a fixed sentinel value under service
|
||||
`sherlock-preflight` / account `probe`, reads it back, deletes it.
|
||||
2. On success: returns a live `Store` backed by the OS keyring.
|
||||
3. On failure: returns `*keyring.UnavailableError` whose `Cause` field
|
||||
wraps the underlying error and whose `Hint` field carries a
|
||||
per-OS one-line remediation. `Error()` includes both, so callers
|
||||
normally just print the error and move on; `keyring.IsUnavailable(err)`
|
||||
is the type-predicate for branching.
|
||||
|
||||
CLI behaviour: any failure prints the error (which already includes
|
||||
the hint) and exits with code `3`. MCPs inherit the same behaviour
|
||||
because they construct their Store via the same `keyring.Open()`.
|
||||
|
||||
## Platform notes
|
||||
|
||||
| OS | Backend | Common setup snag |
|
||||
|---|---|---|
|
||||
| Linux | Secret Service (D-Bus) | `gnome-keyring-daemon`, KWallet, or `keepassxc` with Secret Service enabled must be running for the session. Headless boxes need `gnome-keyring-daemon --components=secrets` started inside the session bus. |
|
||||
| macOS | Keychain | Works out of the box. First write may prompt for unlock. |
|
||||
| Windows | Credential Manager | Works out of the box. |
|
||||
|
||||
## Why not files
|
||||
|
||||
We considered an age-encrypted token blob and dropped it: the keyring
|
||||
gives us OS-managed locking, session affinity, and consistent
|
||||
multi-user behaviour for free, and avoids inventing a new key
|
||||
management story. The trade-off — Linux headless setups need a
|
||||
deliberate session keyring — is the right one for a homelab operator
|
||||
tool where the operator already has a desktop session.
|
||||
Linux needs a Secret Service provider available in the user session. macOS uses
|
||||
Keychain; Windows uses Credential Manager.
|
||||
|
||||
+16
-59
@@ -1,70 +1,27 @@
|
||||
# Versioning & self-update
|
||||
|
||||
Sherlock is versioned by git tags on the public repo and can update
|
||||
itself in place.
|
||||
`VERSION` at the repo root is the release source of truth.
|
||||
|
||||
## Version scheme
|
||||
## Releases
|
||||
|
||||
- The source of truth is the **`VERSION`** file at the repo root, holding
|
||||
a full semver string (e.g. `0.1.0`).
|
||||
- On every push to `main`, the [release workflow](../.gitea/workflows/release.yaml)
|
||||
runs the full CI (gofmt, vet, errcheck, staticcheck, `test -race`,
|
||||
build) and then — **only if every gate passed** — reads `VERSION` and,
|
||||
**if the matching tag `vVERSION` does not already exist**, creates and
|
||||
pushes it at that commit. A tag is never created on a red build.
|
||||
Cutting a release is a one-line edit to `VERSION` — no manual tagging.
|
||||
- Binaries bake their version in at build time via
|
||||
`-ldflags "-X main.Version=<v>"`. A build without that flag reports the
|
||||
sentinel `0.0.0-dev` and is treated as "not a release".
|
||||
On pushes to `main`, the release workflow runs formatting, static analysis, race
|
||||
tests, and build. If all gates pass, it creates tag `vVERSION` when that tag
|
||||
does not already exist.
|
||||
|
||||
## How a build gets its version
|
||||
Binaries receive their version at build time through linker flags. Builds
|
||||
without that flag report `0.0.0-dev` and skip passive update hints.
|
||||
|
||||
| Build path | Version baked |
|
||||
|---|---|
|
||||
| `go run ./setup` (default) | the latest `vX.Y.Z` tag in the cache checkout, or the `VERSION` file if there are no tags yet |
|
||||
| `go run ./setup --local` | the `VERSION` file in your working tree |
|
||||
| `sherlock update` | the latest published tag |
|
||||
| plain `go install ./cmd/...` | `0.0.0-dev` (no `-ldflags`) |
|
||||
## Update checks
|
||||
|
||||
## Update checking
|
||||
|
||||
`internal/selfupdate` reads the repo's tags through Gitea's public REST
|
||||
API (no auth) and compares the highest `vX.Y.Z` against the running
|
||||
binary using `golang.org/x/mod/semver`.
|
||||
|
||||
- **`sherlock version`** prints the running version and, for release
|
||||
builds, appends a one-line hint if a newer tag exists.
|
||||
- **Agent launches** (`sherlock copilot`, …) do a bounded, best-effort
|
||||
check just before handing off to the agent and print the same hint.
|
||||
It never blocks for more than ~1.5s and is silent on dev builds,
|
||||
offline, or when `SHERLOCK_NO_UPDATE_CHECK` is set.
|
||||
|
||||
The check is intentionally **uncached** — it runs each time — but is
|
||||
always bounded and non-fatal, so it can't break or noticeably delay a
|
||||
command.
|
||||
Release builds check the public Gitea tags API in two places: `sherlock version`
|
||||
and just before agent handoff. The check is bounded, best-effort, uncached, and
|
||||
disabled by `SHERLOCK_NO_UPDATE_CHECK`.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
sherlock update # update to the latest release if newer
|
||||
sherlock update --force # reinstall the latest even if not newer
|
||||
```
|
||||
`sherlock update` installs the latest release through the shared installer.
|
||||
`--force` reinstalls even when already current. Updates rebuild binaries and
|
||||
completions but leave config untouched.
|
||||
|
||||
`update` resolves the latest tag, then calls the shared installer
|
||||
(`internal/installer`) — the same code `go run ./setup` runs. The
|
||||
installer clones (first time) or fetches the source under
|
||||
`${XDG_CACHE_HOME:-~/.cache}/sherlock/src`, checks out the latest tag,
|
||||
runs `go install -ldflags "-X main.Version=<tag>" ./cmd/...`, and
|
||||
installs shell completions. An update never touches your config.
|
||||
|
||||
There is no duplicate install logic: `go run ./setup` and
|
||||
`sherlock update` differ only in whether they seed/edit the config. Both
|
||||
require `git` and the Go toolchain on `PATH`.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `SHERLOCK_NO_UPDATE_CHECK` | Disable the passive update hint. |
|
||||
| `SHERLOCK_UPDATE_TAGS_URL` | Override the tags API URL (forks, mirrors, tests). |
|
||||
| `SHERLOCK_UPDATE_REPO_URL` | Override the clone URL used by the installer. |
|
||||
Overrides: `SHERLOCK_UPDATE_TAGS_URL` for tag lookup and
|
||||
`SHERLOCK_UPDATE_REPO_URL` for clone/update source.
|
||||
|
||||
@@ -7,7 +7,7 @@ require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/grafana/mcp-grafana v0.15.2
|
||||
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389
|
||||
github.com/mark3labs/mcp-go v0.46.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
|
||||
@@ -204,8 +204,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.290.1 h1:wNX4R8sHxEAmtmFhmV05IsLGznz
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.290.1/go.mod h1:KDkcxp1XqbKz0WD/q9p98Cf5Wp50LG0NkoPlVlptSWs=
|
||||
github.com/grafana/incident-go v0.0.0-20251003115753-d71681611ddd h1:y8uJA/UmFHjwNWAvppxDRq+w9zIQmb0z79YVV2vS96g=
|
||||
github.com/grafana/incident-go v0.0.0-20251003115753-d71681611ddd/go.mod h1:3QDfdZOWKRxNhMJFL+0C/+12+jLNHDlt0VKNr/i9Daw=
|
||||
github.com/grafana/mcp-grafana v0.15.2 h1:E2OqH8mM4M9SiyhByxjT9toFnPG0IVWxthzKUPozl34=
|
||||
github.com/grafana/mcp-grafana v0.15.2/go.mod h1:LT3LLGh4ZmjqasENuFicV8iJkyuGuVNZztyPolDlBqI=
|
||||
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389 h1:teRFmGepvMKVA/VNj/IcQkJuS3q0Rijk5yYSCmiLbxE=
|
||||
github.com/grafana/mcp-grafana v0.15.3-0.20260613165141-77d81c6b7389/go.mod h1:LT3LLGh4ZmjqasENuFicV8iJkyuGuVNZztyPolDlBqI=
|
||||
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
|
||||
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ package browser
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Open starts the OS's default browser pointed at url, then returns
|
||||
@@ -15,9 +16,29 @@ import (
|
||||
// itself reports — there's no portable way to know whether the user
|
||||
// actually loaded the page.
|
||||
func Open(url string) error {
|
||||
return OpenWith("", url)
|
||||
}
|
||||
|
||||
// OpenWith starts browser pointed at url, then returns immediately. If
|
||||
// browser is empty, it falls back to the OS default helper used by Open.
|
||||
func OpenWith(browser, url string) error {
|
||||
bin, args := commandFor(browser, url, runtime.GOOS)
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func commandFor(browser, url, goos string) (string, []string) {
|
||||
if browser = strings.TrimSpace(browser); browser != "" {
|
||||
return browser, []string{url}
|
||||
}
|
||||
|
||||
var bin string
|
||||
var args []string
|
||||
switch runtime.GOOS {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
bin = "open"
|
||||
args = []string{url}
|
||||
@@ -28,10 +49,5 @@ func Open(url string) error {
|
||||
bin = "xdg-open"
|
||||
args = []string{url}
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
return bin, args
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandFor_Defaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
wantBin string
|
||||
wantArgs []string
|
||||
}{
|
||||
{name: "linux", goos: "linux", wantBin: "xdg-open", wantArgs: []string{"https://example.com"}},
|
||||
{name: "darwin", goos: "darwin", wantBin: "open", wantArgs: []string{"https://example.com"}},
|
||||
{name: "windows", goos: "windows", wantBin: "rundll32", wantArgs: []string{"url.dll,FileProtocolHandler", "https://example.com"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBin, gotArgs := commandFor("", "https://example.com", tt.goos)
|
||||
if gotBin != tt.wantBin || !reflect.DeepEqual(gotArgs, tt.wantArgs) {
|
||||
t.Fatalf("commandFor = %q %v, want %q %v", gotBin, gotArgs, tt.wantBin, tt.wantArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandFor_CustomBrowser(t *testing.T) {
|
||||
gotBin, gotArgs := commandFor(" firefox ", "https://example.com", "linux")
|
||||
wantArgs := []string{"https://example.com"}
|
||||
if gotBin != "firefox" || !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("commandFor = %q %v, want firefox %v", gotBin, gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
//
|
||||
// Shape:
|
||||
//
|
||||
// [oauth]
|
||||
// browser = "firefox" # optional; default OS browser when empty
|
||||
//
|
||||
// [providers.sherlock-cli] # a reusable OAuth provider
|
||||
// issuer = "https://id.example/application/o/sherlock-cli/"
|
||||
// client_id = "abc123"
|
||||
@@ -66,8 +69,16 @@ type Service struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
}
|
||||
|
||||
// OAuth contains global OAuth-flow preferences.
|
||||
type OAuth struct {
|
||||
// Browser is an optional executable name or path used to open OAuth
|
||||
// login URLs. Empty means the OS default browser.
|
||||
Browser string `toml:"browser"`
|
||||
}
|
||||
|
||||
// Config is the whole parsed file.
|
||||
type Config struct {
|
||||
OAuth OAuth `toml:"oauth"`
|
||||
Providers map[string]Provider `toml:"providers"`
|
||||
Services map[string]Service `toml:"services"`
|
||||
|
||||
@@ -83,6 +94,7 @@ type Resolved struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
BaseURL string
|
||||
OAuthBrowser string
|
||||
}
|
||||
|
||||
// DefaultPath returns the config-file path sherlock reads, honouring
|
||||
@@ -155,6 +167,7 @@ func (c *Config) Service(name string) (Resolved, error) {
|
||||
ClientID: svc.ClientID,
|
||||
ClientSecret: svc.ClientSecret,
|
||||
BaseURL: svc.BaseURL,
|
||||
OAuthBrowser: c.OAuth.Browser,
|
||||
}
|
||||
|
||||
if svc.Provider != "" {
|
||||
|
||||
@@ -63,6 +63,26 @@ base_url = "https://gitea.example"
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_OAuthBrowser(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[oauth]
|
||||
browser = "firefox"
|
||||
|
||||
[services.gitea]
|
||||
issuer = "https://gitea.example"
|
||||
client_id = "gitea-client"
|
||||
base_url = "https://gitea.example"
|
||||
`)
|
||||
c, _ := LoadFrom(path)
|
||||
got, err := c.Service("gitea")
|
||||
if err != nil {
|
||||
t.Fatalf("Service: %v", err)
|
||||
}
|
||||
if got.OAuthBrowser != "firefox" {
|
||||
t.Fatalf("OAuthBrowser = %q", got.OAuthBrowser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_UnknownService(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[services.gitea]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user