8 Commits

Author SHA1 Message Date
Alexandru Macocian 84958c7ef3 Support for local agents
Release / release (push) Successful in 17s
2026-06-15 19:03:56 +02:00
Alexandru Macocian f6e8186b46 Support for custom browser
Release / release (push) Successful in 26s
2026-06-15 17:19:22 +02:00
amacocian c943b1f4fb Fix staticcheck
Release / release (push) Successful in 19s
2026-06-14 21:39:21 +02:00
amacocian 59eaf9e47d SearXNG support
Release / release (push) Failing after 6s
2026-06-14 21:33:22 +02:00
amacocian a96f2afe6f Move to downstream fixes
Release / release (push) Successful in 3m48s
2026-06-13 19:49:52 +02:00
amacocian 31829b9733 Replace grafana-mcp with patched fork
Release / release (push) Successful in 25s
2026-06-13 16:41:17 +02:00
amacocian 46e1e0d8ef Cleanup docs
Release / release (push) Successful in 14s
2026-06-13 15:08:52 +02:00
amacocian d178b048b4 Completion for updates
Release / release (push) Successful in 16s
2026-06-13 14:50:53 +02:00
47 changed files with 1382 additions and 1276 deletions
+4 -1
View File
@@ -1,6 +1,8 @@
# sherlock # 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 ## Docs
@@ -14,5 +16,6 @@ Per-operator credential wallet + agent-CLI wrapper for the Charlie homelab. Hold
- [gitea-mcp](docs/gitea-mcp.md) - [gitea-mcp](docs/gitea-mcp.md)
- [grafana-mcp](docs/grafana-mcp.md) - [grafana-mcp](docs/grafana-mcp.md)
- [gssh-mcp](docs/gssh-mcp.md) - [gssh-mcp](docs/gssh-mcp.md)
- [searxng-mcp](docs/searxng-mcp.md)
- [gssh integration](docs/gssh-integration.md) - [gssh integration](docs/gssh-integration.md)
- [Conventions](docs/conventions.md) - [Conventions](docs/conventions.md)
+1 -1
View File
@@ -1 +1 @@
0.1.2 0.1.8
+1 -1
View File
@@ -117,7 +117,7 @@ func main() {
OnAuthURL: func(u string) { OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:") fmt.Fprintln(os.Stderr, "gitea-mcp: opening browser for Gitea OAuth login:")
fmt.Fprintln(os.Stderr, " ", u) 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, "gitea-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)") fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
} }
+1 -1
View File
@@ -114,7 +114,7 @@ func main() {
OnAuthURL: func(u string) { OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "grafana-mcp: opening browser for Grafana OAuth login:") fmt.Fprintln(os.Stderr, "grafana-mcp: opening browser for Grafana OAuth login:")
fmt.Fprintln(os.Stderr, " ", u) 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, "grafana-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)") fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
} }
+1 -1
View File
@@ -101,7 +101,7 @@ func main() {
OnAuthURL: func(u string) { OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:") fmt.Fprintln(os.Stderr, "gssh-mcp: opening browser for Gssh OAuth login:")
fmt.Fprintln(os.Stderr, " ", u) 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, "gssh-mcp: open browser:", err)
fmt.Fprintln(os.Stderr, "(visit the URL above manually)") fmt.Fprintln(os.Stderr, "(visit the URL above manually)")
} }
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var ErrUnauthorized = errors.New("searxng: bearer rejected")
type searxngAPI struct {
baseURL string
token func(context.Context) (string, error)
client *http.Client
}
type searchParams struct {
Query string
Categories string
Engines string
Language string
TimeRange string
SafeSearch *int
Page int
Limit int
}
type searxngSearchResponse struct {
Query string `json:"query"`
NumberOfResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Suggestions []string `json:"suggestions,omitempty"`
Answers []string `json:"answers,omitempty"`
Corrections []string `json:"corrections,omitempty"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content,omitempty"`
Engine string `json:"engine,omitempty"`
Engines []string `json:"engines,omitempty"`
Category string `json:"category,omitempty"`
Score float64 `json:"score,omitempty"`
PublishedDate string `json:"publishedDate,omitempty"`
ImgSrc string `json:"img_src,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
}
func (s *searxngAPI) search(ctx context.Context, p searchParams) (searxngSearchResponse, error) {
q := url.Values{
"format": []string{"json"},
"q": []string{p.Query},
}
setIf(q, "categories", p.Categories)
setIf(q, "engines", p.Engines)
setIf(q, "language", p.Language)
setIf(q, "time_range", p.TimeRange)
if p.SafeSearch != nil {
q.Set("safesearch", strconv.Itoa(*p.SafeSearch))
}
if p.Page > 0 {
q.Set("pageno", strconv.Itoa(p.Page))
}
full := s.baseURL + "/search?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
if err != nil {
return searxngSearchResponse{}, err
}
tok, err := s.token(ctx)
if err != nil {
return searxngSearchResponse{}, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return searxngSearchResponse{}, err
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
if err := checkStatus(resp, req); err != nil {
return searxngSearchResponse{}, err
}
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "application/json") {
return searxngSearchResponse{}, fmt.Errorf("searxng: %s returned %q, not JSON; ensure SearXNG enables format=json and Caddy is not serving a login page", req.URL.Path, ct)
}
var out searxngSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return searxngSearchResponse{}, fmt.Errorf("searxng: decode JSON search response: %w", err)
}
if p.Limit > 0 && len(out.Results) > p.Limit {
out.Results = out.Results[:p.Limit]
}
return out, nil
}
func checkStatus(resp *http.Response, req *http.Request) error {
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w by %s (try `sherlock logout searxng` and retry; Caddy must accept the OAuth bearer)", ErrUnauthorized, req.URL.Host)
}
if resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect {
return fmt.Errorf("searxng: %s %s redirected to %q; Caddy is likely requiring browser-session auth instead of accepting the OAuth bearer", req.Method, req.URL.Path, resp.Header.Get("Location"))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("searxng: %s %s: HTTP %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setIf(q url.Values, k, v string) {
if v != "" {
q.Set(k, v)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Command searxng-mcp is sherlock's MCP server for SearXNG. It
// authenticates as the operator against Authentik (OIDC + PKCE,
// loopback callback) and sends the resulting bearer token to the
// Caddy-protected SearXNG JSON search API.
//
// Two modes:
//
// searxng-mcp start the stdio MCP server (agents spawn this)
// searxng-mcp --probe run auth + one search, print result, exit
//
// Deployment configuration (the Authentik issuer/client ID and the
// SearXNG base URL) comes entirely from the sherlock config file; there
// are no compiled-in defaults. See docs/configuration.md.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/authn"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/browser"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/xdg"
)
// serviceName is the wallet key under which SearXNG tokens live, and
// the [services.<serviceName>] section read from the config file. The
// sherlock CLI's `logout searxng` clears the same wallet entry.
const serviceName = "searxng"
// Version is overwritten at build time via -ldflags "-X main.Version=...".
var Version = "0.0.0-dev"
// scopes asked from Authentik. `openid` is required for an ID token;
// `profile`/`email` populate user-info claims in `sherlock status`.
var scopes = []string{"openid", "profile", "email"}
func main() {
probe := flag.Bool("probe", false, "run auth + one SearXNG search, print result, exit")
probeQuery := flag.String("probe-query", "sherlock", "query to use with --probe")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Println(Version)
return
}
svc, err := config.LoadService(serviceName)
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
if errors.Is(err, config.ErrNotFound) {
fmt.Fprintln(os.Stderr, "Create it (see docs/configuration.md) or run the sherlock install script.")
}
os.Exit(2)
}
store, err := keyring.Open()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(3)
}
agent.EnsurePathContainsSiblings()
lockPath, err := xdg.RefreshLockPath()
if err != nil {
fmt.Fprintln(os.Stderr, "searxng-mcp:", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
cfg := authn.Config{
Issuer: svc.Issuer,
ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret,
Scopes: scopes,
}
holder := authn.NewTokenHolder()
src := authn.NewTokenSource(store, serviceName, cfg, authn.EnsureOptions{
LockPath: lockPath,
OnAuthURL: func(u string) {
fmt.Fprintln(os.Stderr, "searxng-mcp: opening browser for SearXNG OAuth login:")
fmt.Fprintln(os.Stderr, " ", u)
if err := browser.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)
}
}
+77
View File
@@ -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
}
+36
View File
@@ -18,6 +18,7 @@ import (
"time" "time"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/agent"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/config"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
) )
@@ -58,6 +59,13 @@ func main() {
// `go install` even if the operator never touched their shell rc. // `go install` even if the operator never touched their shell rc.
agent.EnsurePathContainsSiblings() agent.EnsurePathContainsSiblings()
// Register operator-defined custom agents from config.toml so they
// dispatch just like the built-ins below.
if err := registerCustomAgents(); err != nil {
fmt.Fprintln(os.Stderr, "sherlock:", err)
os.Exit(exitUsage)
}
switch sub { switch sub {
case "status": case "status":
runStatus(store, rest) runStatus(store, rest)
@@ -132,6 +140,11 @@ func runStatus(store keyring.Store, _ []string) {
if len(ts.Scopes) > 0 { if len(ts.Scopes) > 0 {
fmt.Printf(" scopes: %s\n", strings.Join(ts.Scopes, " ")) 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() { if !ts.IDExpiresAt.IsZero() {
fmt.Printf(" id token until %s (%s)\n", fmt.Printf(" id token until %s (%s)\n",
ts.IDExpiresAt.Format(time.RFC3339), ts.IDExpiresAt.Format(time.RFC3339),
@@ -220,6 +233,7 @@ func knownMCPs() (mcp.Servers, error) {
"gitea": {Command: "gitea-mcp"}, "gitea": {Command: "gitea-mcp"},
"grafana": {Command: "grafana-mcp"}, "grafana": {Command: "grafana-mcp"},
"gssh": {Command: "gssh-mcp"}, "gssh": {Command: "gssh-mcp"},
"searxng": {Command: "searxng-mcp"},
} }
for name, spec := range specs { for name, spec := range specs {
abs, err := agent.LookPath(spec.Command) abs, err := agent.LookPath(spec.Command)
@@ -233,6 +247,28 @@ func knownMCPs() (mcp.Servers, error) {
return out, nil return out, nil
} }
// registerCustomAgents loads the operator config (if any) and registers
// each `[agents.<name>]` entry as a dispatchable agent. A missing config
// file is not an error — sherlock simply offers only the built-ins. A
// malformed agent entry (unknown backend, name collision) is fatal, in
// keeping with the config package's "refuse to start rather than
// misbehave" philosophy.
func registerCustomAgents() error {
cfg, err := config.Load()
if err != nil {
if errors.Is(err, config.ErrNotFound) {
return nil
}
return err
}
for name, a := range cfg.Agents {
if err := agent.RegisterCustom(name, a.Command, a.Backend); err != nil {
return err
}
}
return nil
}
func humanizeUntil(t time.Time) string { func humanizeUntil(t time.Time) string {
d := time.Until(t).Round(time.Second) d := time.Until(t).Round(time.Second)
if d < 0 { if d < 0 {
+4 -3
View File
@@ -78,9 +78,10 @@ func runUpdate(args []string) {
} }
// Non-interactive install against the cache checkout (the installer // Non-interactive install against the cache checkout (the installer
// syncs it to the latest tag and bakes that version). Config is left // syncs it to the latest tag and bakes that version). SeedConfig is
// untouched — SeedConfig is off, so an update never opens an editor // off, so update never creates a config or opens an editor, but the
// or clobbers config. // installer may append marked templates for newly shipped config
// sections. Existing values are never clobbered.
if err := installer.Install(context.Background(), installer.Options{ if err := installer.Install(context.Background(), installer.Options{
Local: false, Local: false,
SeedConfig: false, SeedConfig: false,
+6 -1
View File
@@ -1,5 +1,5 @@
# fish completion for sherlock. # fish completion for sherlock.
# Installed by install.sh into $__fish_config_dir/completions/sherlock.fish # Installed by the installer into $__fish_config_dir/completions/sherlock.fish
# when a fish config directory is present. Hand-maintained: keep in sync # when a fish config directory is present. Hand-maintained: keep in sync
# with cmd/sherlock/main.go (subcommands) and internal/agent (agents). # with cmd/sherlock/main.go (subcommands) and internal/agent (agents).
@@ -7,6 +7,7 @@
complete -c sherlock -n '__fish_use_subcommand' -f -a 'status' -d 'Show wallet contents (one entry per service)' complete -c sherlock -n '__fish_use_subcommand' -f -a 'status' -d 'Show wallet contents (one entry per service)'
complete -c sherlock -n '__fish_use_subcommand' -f -a 'logout' -d "Forget all stored tokens, or just one service's" complete -c sherlock -n '__fish_use_subcommand' -f -a 'logout' -d "Forget all stored tokens, or just one service's"
complete -c sherlock -n '__fish_use_subcommand' -f -a 'run' -d 'Spawn an agent (run <agent> [args...])' complete -c sherlock -n '__fish_use_subcommand' -f -a 'run' -d 'Spawn an agent (run <agent> [args...])'
complete -c sherlock -n '__fish_use_subcommand' -f -a 'update' -d 'Update sherlock + MCPs to the latest release'
complete -c sherlock -n '__fish_use_subcommand' -f -a 'version' -d 'Print the sherlock version and exit' complete -c sherlock -n '__fish_use_subcommand' -f -a 'version' -d 'Print the sherlock version and exit'
complete -c sherlock -n '__fish_use_subcommand' -f -a 'help' -d 'Show usage' complete -c sherlock -n '__fish_use_subcommand' -f -a 'help' -d 'Show usage'
@@ -25,6 +26,10 @@ 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 '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 '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 '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'
# ── Top-level flags ────────────────────────────────────────────────── # ── Top-level flags ──────────────────────────────────────────────────
complete -c sherlock -n '__fish_use_subcommand' -l version -s v -f -d 'Print the sherlock version and exit' complete -c sherlock -n '__fish_use_subcommand' -l version -s v -f -d 'Print the sherlock version and exit'
+23
View File
@@ -1,5 +1,11 @@
# sherlock conifugration - lives in $XDG_CONFIG_HOME/sherlock/config.toml # 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 ──────────────────────────────────────────────────────── # ── Providers ────────────────────────────────────────────────────────
# A [providers.<name>] block is a reusable OAuth/OIDC client. Services # A [providers.<name>] block is a reusable OAuth/OIDC client. Services
# point at one via `provider = "<name>"`. Define a provider once and # point at one via `provider = "<name>"`. Define a provider once and
@@ -36,3 +42,20 @@ base_url = "https://grafana.example.com"
[services.gssh] [services.gssh]
provider = "sherlock-cli" provider = "sherlock-cli"
base_url = "https://terminal.example.com" base_url = "https://terminal.example.com"
[services.searxng]
provider = "sherlock-cli"
base_url = "https://search.example.com"
# ── Custom agents ────────────────────────────────────────────────────
# A [agents.<name>] block exposes an operator-owned wrapper command as
# `sherlock <name>`, reusing a built-in backend's MCP/-env conventions.
# `backend` must be "copilot" or "claude" — it tells sherlock how to
# format the MCP config for the wrapped CLI. `command` is the binary to
# exec and defaults to <name> when omitted.
#
# Example: surface a `copilot-local` wrapper (Copilot CLI pointed at a
# self-hosted model via BYOK) as `sherlock copilot-local`.
# [agents.copilot-local]
# command = "copilot-local"
# backend = "copilot"
+44 -88
View File
@@ -1,97 +1,53 @@
# Agents # Agents
How sherlock decides which CLI to spawn when you type Sherlock dispatches built-in agent profiles from `internal/agent/`.
`sherlock copilot` or `sherlock claude`, and how to add a new one.
## 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 | `sherlock <agent> [args...]` and `sherlock run <agent> [args...]` are
|---|---|---|---| equivalent. Unknown agent names exit with usage errors.
| `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. |
## Routing ## Spawn behavior
``` On spawn, sherlock resolves installed MCP binaries (`gitea-mcp`, `grafana-mcp`,
sherlock copilot [args...] ⇢ runs copilot `gssh-mcp`, `searxng-mcp`), skips missing ones with a warning, renders a 0600
sherlock claude [args...] ⇢ runs claude `.mcp.json` file under `$XDG_RUNTIME_DIR/sherlock/`, and `exec`s the target
sherlock run copilot [args...] ⇢ same, explicit form agent.
sherlock run <unknown> ... ⇢ exit 2 with "unknown agent"
sherlock <unknown> ⇢ exit 2 with "unknown subcommand" The child mostly inherits the parent environment. The Claude profile strips
`ANTHROPIC_API_KEY` so a personal key does not override sherlock-managed
behavior.
## Changing agents
Adding a built-in 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`, and the per-CLI
MCP/-env conventions ("backends") live in `internal/agent/backend.go`.
## Custom agents (config)
Operators can register their own agents from `config.toml` without
touching code — handy for wrapper commands such as a `copilot-local`
script that points the Copilot CLI at a self-hosted model:
```toml
[agents.copilot-local]
command = "copilot-local" # binary to exec; defaults to the name if omitted
backend = "copilot" # which CLI's MCP/-env conventions to use
``` ```
The `run` form exists for parity with `cargo run` / `npm run`; the `backend` must be one of the built-in backends (`copilot` or `claude`);
bare alias is the daily-driver form. it tells sherlock how to format the MCP config (`--additional-mcp-config
@<path>` vs `--mcp-config <path>`) and what env to scrub. The rendered
MCP file is named after the agent (`<name>.mcp.json`).
## What sherlock does per spawn `sherlock copilot-local [args...]` then dispatches exactly like a
built-in. A custom agent whose name collides with a built-in agent, or
1. `keyring.Open()` — fail fast if the OS keyring isn't available (returns `*UnavailableError` with a remediation `Hint` field). that names an unknown backend, is a hard startup error. (Names matching a
2. Resolve the agent binary on `$PATH`. Friendly error if missing. reserved subcommand — `status`, `logout`, `run`, `update`, `version`
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/`. are shadowed by that subcommand; reach them via `sherlock run <name>`.)
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.
+23 -58
View File
@@ -1,67 +1,32 @@
# Architecture # 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 | Area | Role |
flowchart TD | --------------------------------------------- | --------------------------------------------------------- |
user["user shell"] -->|`sherlock copilot`| sh[sherlock CLI] | `cmd/sherlock/` | CLI, wallet commands, agent dispatch, update entry point. |
| `cmd/*-mcp/` | Service MCP binaries. Tool details live with each MCP. |
sh -->|syscall.Exec| agent[copilot / claude CLI] | `internal/agent/` | Registered agent profiles and spawn helpers. |
sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/&lt;agent&gt;.mcp.json)] | `internal/mcp/` | MCP config rendering. |
agent -.->|reads| mcpfile | `internal/config/` | TOML loading and service/provider resolution. |
| `internal/authn/` | OAuth/PKCE login, refresh, token sources, locks. |
agent -->|stdio| giteamcp[gitea-mcp] | `internal/keyring/` | OS keyring wallet. |
agent -->|stdio| grafanamcp[grafana-mcp] | `internal/installer/`, `internal/selfupdate/` | Install and update logic. |
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.
## Boundaries ## Boundaries
- Sherlock does **not** federate identities — that's Authentik's job. Sherlock does not federate identities, run a background broker, issue SSH
- Sherlock does **not** issue SSH certificates — gssh already does that internally when it authenticates a WebSocket session. See [gssh-integration.md](gssh-integration.md). credentials, enforce service policy, or define MCP tool behavior. It gets the
- 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. right credential to the right local MCP process.
+25 -116
View File
@@ -1,128 +1,37 @@
# Auth model # 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 For a service such as `gitea`, `grafana`, `gssh`, or `searxng`, the MCP asks
service's Authentik provider (gitea, grafana, miniflux, invidious, …). There is `internal/authn` for a token on first use:
**no master Authentik session**; every entry is service-keyed.
This matches the homelab topology: caddy reverse-proxies the public endpoints 1. Fresh wallet entry: return it.
and enforces edge OIDC for browsers; each downstream service *also* has its own 2. Stale entry with refresh token: refresh under
Authentik OAuth client used to bind a service-side account to the operator's `$XDG_RUNTIME_DIR/sherlock.refresh.lock`, persist, return.
Authentik identity. An MCP that wants to call `gitea.x` needs a token issued by 3. Missing or unrecoverable entry: run a PKCE browser flow on `127.0.0.1:6990`,
gitea's specific Authentik provider — caddy + gitea both verify it. The same serialized by `$XDG_RUNTIME_DIR/sherlock.login.lock`, persist, return.
goes for every other service. One master token cannot satisfy all of them.
## 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 ## Service identity
authenticate. Each MCP at startup calls
```go The config supplies issuer, client ID/secret, and base URL. Scopes are owned by
ts, err := authn.Ensure(ctx, store, "gitea", cfg, opts) 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. ## User controls
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.
The first time an operator runs `sherlock copilot` in a fresh environment, the `sherlock status` lists stored sessions. `sherlock logout` clears all sessions;
agent spawns its MCPs; each MCP triggers its own browser flow as it needs one. `sherlock logout <service>` clears one.
Subsequent runs are silent until the refresh window opens.
### Concurrent first-use is serialised Sherlock does not use static service-account tokens, share sessions between
operators, or perform RFC 8693 token exchange.
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.
+27 -72
View File
@@ -1,89 +1,44 @@
# Configuration # Configuration
Sherlock reads all deployment-specific values — the Authentik Sherlock reads deployment values from one TOML file. Missing files, sections, or
issuer/client IDs and each service's base URL — from a single TOML file. required fields are hard errors.
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.
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 ## Location
Resolved in this order: Resolved in order:
1. `$SHERLOCK_CONFIG` (an explicit path; handy for tests / non-standard 1. `$SHERLOCK_CONFIG`
installs). 2. `$XDG_CONFIG_HOME/sherlock/config.toml`
2. `$XDG_CONFIG_HOME/sherlock/config.toml`. 3. `$HOME/.config/sherlock/config.toml`
3. `$HOME/.config/sherlock/config.toml`.
## Shape ## Shape
```toml `[oauth]` is optional. Set `browser = "<executable>"` to open OAuth login URLs
[providers.sherlock-cli] with a specific browser, for example `browser = "firefox"`. Leave it unset to
issuer = "https://id.example.com/application/o/sherlock-cli/" use the OS default browser.
client_id = "…"
[services.gitea] `[providers.<name>]` defines a reusable OAuth/OIDC identity: `issuer`,
issuer = "https://gitea.example.com" `client_id`, optional `client_secret`.
client_id = "…"
base_url = "https://gitea.example.com"
[services.grafana] `[services.<name>]` defines one MCP target. The service name is also the wallet
provider = "sherlock-cli" key used by `sherlock status` and `sherlock logout <name>`. Each service
base_url = "https://grafana.example.com" requires `base_url` plus either:
[services.gssh] - `provider = "<name>"`, resolved from `[providers.<name>]`, or
provider = "sherlock-cli" - inline `issuer`, `client_id`, and optional `client_secret`.
base_url = "https://terminal.example.com"
```
### Providers Mixing `provider` with inline identity fields is rejected.
A `[providers.<name>]` block is a reusable OAuth/OIDC client ## Not configured here
(`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`.
### 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 To add a service, add its config entry and wire or install the matching MCP
config key and the wallet key (`sherlock logout <name>`). A service binary.
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.
+31 -49
View File
@@ -1,68 +1,50 @@
# Conventions # 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
``` - Shippable binaries live under `cmd/<name>/`.
cmd/ one subdir per shippable binary; package main only - The bootstrap installer lives under `setup/`; shared install logic lives in
setup/ bootstrap installer entry (go run ./setup); not shipped `internal/installer/`.
internal/ every other package; never imported outside this module - Shared Go code lives under `internal/`. Add no top-level `pkg/` until there is
internal/installer/ the one install/update implementation (shared by setup + `sherlock update`) an external consumer.
docs/ one topic per file (see "Docs" below) - `README.md` is a short description plus a docs index.
VERSION release version source of truth (see versioning.md) - `VERSION` is the release version source of truth.
README.md TOC only
```
No top-level `pkg/` until we have an external consumer.
## Go ## Go
- Module path: `gitea.alexandru.macocian.me/amacocian/sherlock`. - 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). - Target Go version: the `go` directive in `go.mod`.
- One `package main` per `cmd/<binary>/`. No multiple-`main`-files trickery. - One `package main` per `cmd/<binary>/`.
- 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). - Non-main packages have a package doc comment in their leading source file.
- Third-party deps are added with a one-line justification. The current set: - New third-party dependencies need a short justification with the change.
- `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.
## Docs ## Docs
- `README.md` is **TOC only**. One-line description + a bulleted index of `docs/*.md`. - Keep docs short and functional.
- **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`. - Prefer links to source files over copied API/tool examples.
- Cross-link aggressively: every doc should link to the other docs whose concerns it touches. - Do not add layout diagrams. Use prose for relationships and source links for
- Mermaid diagrams over images or ASCII art. Diagrams live next to the prose that explains them. details.
- One topic per `docs/*.md` file; link from `README.md`.
## Naming ## Naming
- Binaries are kebab-case (`gitea-mcp`, `gssh-mcp`). - Binaries are kebab-case: `gitea-mcp`, `gssh-mcp`, `searxng-mcp`.
- Built-in agent profiles match the agent's canonical CLI name (`copilot`, `claude`, `aider`). - Agent names match the wrapped CLI: `copilot`, `claude`.
- Service registry files match the service's canonical name (`gitea.toml`, `grafana.toml`). - Service names are the config key and wallet key: `gitea`, `grafana`, `gssh`,
- Env vars are `<SERVICE>_TOKEN` (e.g. `GITEA_TOKEN`, `GSSH_TOKEN`). `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. - Commit style: `area: short imperative`.
- **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. - Keep one logical change per commit.
- `.gitea/workflows/release.yaml` runs formatting, static analysis, race tests,
## Commits and build.
- Tags are created from `VERSION` only after the main-branch release workflow
- Conventional-ish: `area: short imperative` (e.g. `authn: persist client_id in TokenSet`, `docs: add gssh-integration`). passes.
- 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.
## Security ## Security
- No secrets in this repo, ever. Not in tests, not in fixtures, not in comments. No secrets in the repository. OAuth tokens live in the OS keyring; config files
- The operator's Authentik tokens and (Phase 2+) per-service tokens live in the OS keyring. See [storage.md](storage.md). contain deployment metadata only.
+21 -255
View File
@@ -1,264 +1,30 @@
# gitea-mcp # gitea-mcp
The first sherlock MCP. Wraps a small slice of the Gitea REST API Stdio MCP for Gitea's REST API. It exposes read-only access to user,
(`whoami`, `list_repos`) using a Gitea-issued OAuth2 bearer token. 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 Config is `[services.gitea]` with inline `issuer`, `client_id`, optional
Authentik (your homelab SSO). This is how human accounts get `client_secret`, and `base_url`. Scopes are compiled in `cmd/gitea-mcp/main.go`;
provisioned the first time someone visits Gitea. after scope changes, clear the wallet entry with `sherlock logout 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.
`gitea-mcp` uses (2). The flow does not touch Authentik at all from ## Operation
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.
This is deliberate. Going through Authentik would require Gitea to When `gitea-mcp` is installed, `sherlock <agent>` includes it in the generated
trust Authentik-issued bearer tokens at its API layer, which Gitea MCP config. The first tool call authenticates lazily; later calls use refreshed
doesn't do out of the box. Going through Gitea's own OAuth2 server is keyring tokens. `gitea-mcp --probe` verifies auth and one API call without an
the documented, supported path. agent.
### Known Gitea scope limitation ## Known limitation
Gitea's OAuth2 server does **not** honour the full scope list sherlock Some Gitea versions narrow OAuth grants to `read:user` and `read:repository`.
requests. We send `openid profile email read:user read:repository Tools that require issue, organisation, or package scopes can return 403 even
read:issue read:organization read:package`, but Gitea silently grants though the MCP starts correctly. That is a Gitea-side scope grant issue, not a
only `read:repository read:user` regardless. Verified end-to-end sherlock startup failure.
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"
+17 -128
View File
@@ -1,136 +1,25 @@
# grafana-mcp # grafana-mcp
Sherlock's Grafana MCP **imports Grafana Labs' upstream Read-only Grafana MCP. Sherlock imports Grafana Labs' upstream `mcp-grafana` as
[`mcp-grafana`](https://github.com/grafana/mcp-grafana) as a Go a Go package and registers the read-only search, datasource, Prometheus, Loki,
package** and serves a read-only subset of its tools in-process. There alerting, dashboard, folder, navigation, and annotation categories. Exact tool
is no separate `mcp-grafana` binary, no `uvx`, and no `exec`. behavior belongs to upstream and `cmd/grafana-mcp/main.go`.
It authenticates as the operator the same way `gitea-mcp` and ## Auth
`gssh-mcp` do — OAuth + PKCE against Authentik, token stored in the OS
keyring — so the agent never sees a Grafana service-account token.
## 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 must accept external JWT bearers on its API through `[auth.jwt]`.
`GRAFANA_API_KEY`, or Grafana username/password auth. 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 ## Operation
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.
Only read-only tool categories are registered (search, datasource, Config is `[services.grafana]`, usually pointing at the shared `sherlock-cli`
prometheus, loki, alerting, dashboard, folder, navigation, annotations); provider plus `base_url`. The first tool call authenticates lazily;
upstream write tools are disabled. `grafana-mcp --probe` verifies auth and `GET /api/user`.
`sherlock logout grafana` clears the session.
## Why generic OAuth is not enough (and what is) All exposed tools are read-only and still subject to Grafana RBAC.
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.
+15 -55
View File
@@ -1,61 +1,21 @@
# gssh integration # 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: Current contract used by sherlock:
- 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`).
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 If that contract changes, `cmd/gssh-mcp/` is the only sherlock area that should
follow.
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.
+18 -147
View File
@@ -1,157 +1,28 @@
# gssh-mcp # gssh-mcp
sherlock's MCP server for **[Gssh](https://terminal.alexandru.macocian.me)**, Stdio MCP for the Gssh gateway. It lets an agent list allowed hosts, initialize
the homelab SSH gateway. Lets an agent run a single shell command on the operator's Gssh session, and run one command on one host without exposing
an allow-listed host without standing up a PTY, using the operator's local SSH keys.
own JWT-authenticated session.
## 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-mcp` uses sherlock-managed OAuth with wallet key `gssh`, normally through
Gssh's browser PTY login via server-side OIDC code flow. the shared Authentik `sherlock-cli` provider. Gssh must trust that issuer and
2. **`sherlock-cli`** (Public, PKCE) — a separate provider sherlock audience for bearer auth.
authenticates against from the CLI. No client secret, redirect URI
pinned to `http://127.0.0.1:6990/callback`.
Gssh's JwtBearer scheme accepts JWTs from either provider via Config is `[services.gssh]` with provider or inline OAuth identity plus
comma-separated `OIDC__ApiAudience` + `OIDC__BearerIssuers`. JWKS `base_url`. `sherlock logout gssh` clears the session.
verification is anchored on a single discovery URL — all providers in
an Authentik tenant share the signing key, so one fetch validates
both.
The `sherlock-cli` provider can be reused by every future ## Operation
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.
## 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: Set `SHERLOCK_DEBUG_LOG` to capture MCP-side HTTP/WebSocket traces.
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.
+26 -87
View File
@@ -1,101 +1,40 @@
# Installation # Installation
Sherlock installs from a clone of this repo with a single Go command. Install from a clone with `go run ./setup`. `./install.sh` is a thin wrapper
around the same 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
```
## Requirements ## Requirements
- The **Go toolchain** on `PATH` (the installer builds from source). - Go toolchain on `PATH`.
- **git** on `PATH` (to clone the source; not needed with `--local`). - `git` on `PATH`, unless using `--local`.
- An **OS keyring** / Secret Service provider (the wallet lives there; - OS keyring/Secret Service available.
see [storage.md](storage.md)). - The Go install directory on `PATH`.
- The install dir (`go env GOBIN`, else `$(go env GOPATH)/bin`) on your - The agent CLI you plan to launch (`copilot`, `claude`, ...).
`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`).
## 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 | One implementation backs both bootstrap setup and `sherlock update`.
|---|---|
| `-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. |
## Env overrides ## Flags and environment
| Variable | Effect | Setup flags: `-y`/`--yes` skips the editor, `--config-only` edits config without
|---|---| installing, `--local` builds the current checkout, `-h`/`--help` prints usage.
| `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). | Environment: `SHERLOCK_CONFIG` selects the config path,
| `VISUAL` / `EDITOR` | Editor to open. | `SHERLOCK_UPDATE_REPO_URL` selects the source repo, and `VISUAL`/`EDITOR`
selects the editor.
## Updating ## 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 Re-running setup is safe: it preserves config and reinstalls binaries.
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).
+23
View File
@@ -0,0 +1,23 @@
# searxng-mcp
Stdio MCP for a SearXNG instance. It exposes web search through SearXNG's JSON
`/search` API. Exact tool schemas live in `cmd/searxng-mcp/tools.go`.
## Auth
`searxng-mcp` uses sherlock-managed OAuth with wallet key `searxng`, normally
through the shared Authentik `sherlock-cli` provider. It sends a fresh bearer to
the Caddy-protected SearXNG origin on every search request.
Caddy must accept that OAuth bearer for API requests. Browser SSO redirects or
session cookies alone are not enough for an MCP subprocess. SearXNG must also
enable JSON output for `/search?format=json`.
Config is `[services.searxng]` with provider or inline OAuth identity plus
`base_url`. `sherlock logout searxng` clears the session.
## Operation
When `searxng-mcp` is installed, `sherlock <agent>` includes it in the generated
MCP config. The first tool call authenticates lazily. `searxng-mcp --probe`
verifies auth against Caddy and one SearXNG JSON search without an agent.
+21 -81
View File
@@ -1,93 +1,33 @@
# Storage # 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 | Each service has one wallet entry under keyring service `sherlock` and account
|---|---| `service:<name>`. A `services-index` side entry makes `sherlock status` portable
| 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. | across keyring backends.
| 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). |
## 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 `keyring.Open()` probes the OS keyring before returning a store. Missing or
type TokenSet struct { locked keyrings return `*keyring.UnavailableError` with a remediation hint;
IDToken string CLI/MCP callers exit with keyring failure rather than silently falling back.
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
}
```
Serialized as a single JSON blob per service entry — the keyring ## Runtime files
exposes one secret per `(service, account)` pair and we don't want to
round-trip multiple secrets per session.
`Issuer` + `ClientID` + `Scopes` are persisted alongside the tokens so Runtime files live under `$XDG_RUNTIME_DIR/sherlock/` or the sibling runtime
refresh and re-login work from any process, with no dependency on the directory:
shell environment that originally created the entry.
## 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 Linux needs a Secret Service provider available in the user session. macOS uses
type Store interface { Keychain; Windows uses Credential Manager.
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.
+16 -59
View File
@@ -1,70 +1,27 @@
# Versioning & self-update # Versioning & self-update
Sherlock is versioned by git tags on the public repo and can update `VERSION` at the repo root is the release source of truth.
itself in place.
## Version scheme ## Releases
- The source of truth is the **`VERSION`** file at the repo root, holding On pushes to `main`, the release workflow runs formatting, static analysis, race
a full semver string (e.g. `0.1.0`). tests, and build. If all gates pass, it creates tag `vVERSION` when that tag
- On every push to `main`, the [release workflow](../.gitea/workflows/release.yaml) does not already exist.
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".
## 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 | ## Update checks
|---|---|
| `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 checking 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
`internal/selfupdate` reads the repo's tags through Gitea's public REST disabled by `SHERLOCK_NO_UPDATE_CHECK`.
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.
## Updating ## Updating
```bash `sherlock update` installs the latest release through the shared installer.
sherlock update # update to the latest release if newer `--force` reinstalls even when already current. Updates rebuild binaries and
sherlock update --force # reinstall the latest even if not newer completions but leave config untouched.
```
`update` resolves the latest tag, then calls the shared installer Overrides: `SHERLOCK_UPDATE_TAGS_URL` for tag lookup and
(`internal/installer`) — the same code `go run ./setup` runs. The `SHERLOCK_UPDATE_REPO_URL` for clone/update source.
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. |
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -7,7 +7,7 @@ require (
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.18.0 github.com/coreos/go-oidc/v3 v3.18.0
github.com/go-jose/go-jose/v4 v4.1.4 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/mark3labs/mcp-go v0.46.0
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/zalando/go-keyring v0.2.8 github.com/zalando/go-keyring v0.2.8
+2 -2
View File
@@ -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/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 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/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.3-0.20260613165141-77d81c6b7389 h1:teRFmGepvMKVA/VNj/IcQkJuS3q0Rijk5yYSCmiLbxE=
github.com/grafana/mcp-grafana v0.15.2/go.mod h1:LT3LLGh4ZmjqasENuFicV8iJkyuGuVNZztyPolDlBqI= 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 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= 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= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
+11 -5
View File
@@ -1,9 +1,15 @@
// Package agent dispatches `sherlock <name>` invocations to a small // Package agent dispatches `sherlock <name>` invocations to a small
// set of supported CLIs (Copilot, Claude Code, …). Each agent is a // set of supported CLIs (Copilot, Claude Code, …). Each built-in agent
// concrete type in its own file that implements the Agent interface; // is a concrete type in its own file that implements the Agent
// they register themselves at init time. Adding a new agent is a // interface; they register themselves at init time. Adding a new
// matter of dropping a new file in this package — there is no TOML // built-in is a matter of dropping a new file in this package.
// schema and no user-facing extensibility surface, by design. //
// Operators can also declare custom agents in config.toml's
// `[agents.<name>]` table — a wrapper command plus a backend type
// ("copilot" or "claude") that selects the MCP-config and env
// conventions. Those are added at runtime via RegisterCustom. The
// per-CLI quirks shared by built-in and custom agents live in
// backend.go.
// //
// The reusable bits live in this package too (exec.go for env/exec // The reusable bits live in this package too (exec.go for env/exec
// helpers; the mcp sibling package for MCP-config rendering). Agent // helpers; the mcp sibling package for MCP-config rendering). Agent
+78
View File
@@ -0,0 +1,78 @@
package agent
import (
"fmt"
"sort"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
// backend captures the per-CLI quirks shared by every agent that speaks
// to a given coding-assistant CLI: how the rendered MCP-config file is
// passed on the command line, and which env vars must be scrubbed from
// the child process.
//
// Built-in agents (copilot, claude) and operator-defined custom agents
// from config.toml both spawn through a backend, so a `copilot-local`
// wrapper formats its MCP config exactly like the real `copilot`.
type backend struct {
// mcpArgs returns the CLI flags that point the agent at the rendered
// MCP-config file at path.
mcpArgs func(path string) []string
// forbidEnv lists env vars stripped from the child (e.g. a personal
// ANTHROPIC_API_KEY that must not override broker-managed behavior).
forbidEnv []string
}
// backends is the set of known backend types. The keys are the values
// operators put in `[agents.<name>].backend`.
var backends = map[string]backend{
"copilot": {
// The `@` prefix tells Copilot to treat the argument as a file
// path rather than inline JSON.
mcpArgs: func(p string) []string { return []string{"--additional-mcp-config", "@" + p} },
},
"claude": {
mcpArgs: func(p string) []string { return []string{"--mcp-config", p} },
forbidEnv: []string{"ANTHROPIC_API_KEY"},
},
}
// backendNames returns the known backend type names, sorted, for error
// messages.
func backendNames() []string {
out := make([]string, 0, len(backends))
for n := range backends {
out = append(out, n)
}
sort.Strings(out)
return out
}
// spawn is the shared spawn path used by both built-in and custom
// agents. name selects the rendered MCP-config file name; command is the
// binary to exec. On success it does not return (the process is
// replaced); any returned error is a setup failure.
func (b backend) spawn(name, command string, ctx Context, args []string) error {
bin, err := LookPath(command)
if err != nil {
return err
}
mcpPath, err := mcp.Render(name, ctx.Servers)
if err != nil {
return err
}
argv := append([]string{bin}, b.mcpArgs(mcpPath)...)
argv = append(argv, args...)
return DefaultExecer.Exec(bin, argv, BuildEnv(b.forbidEnv, nil))
}
// resolveBackend returns the backend for a type name, with a friendly
// error listing the valid choices when it is unknown.
func resolveBackend(name string) (backend, error) {
b, ok := backends[name]
if !ok {
return backend{}, fmt.Errorf("unknown backend %q (want one of: %v)", name, backendNames())
}
return b, nil
}
+4 -21
View File
@@ -1,9 +1,5 @@
package agent package agent
import (
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
func init() { Register(&claude{}) } func init() { Register(&claude{}) }
// claude integrates Anthropic's Claude Code CLI (npm package // claude integrates Anthropic's Claude Code CLI (npm package
@@ -14,27 +10,14 @@ func init() { Register(&claude{}) }
// We also forbid ANTHROPIC_API_KEY in the child env so an // We also forbid ANTHROPIC_API_KEY in the child env so an
// inadvertently-set personal key can't override the broker-managed // inadvertently-set personal key can't override the broker-managed
// session. (Phase 2 will surface a proper Anthropic auth grant.) // session. (Phase 2 will surface a proper Anthropic auth grant.)
//
// The per-CLI quirks (flag shape, env scrubbing) live in the shared
// "claude" backend; this type is just the built-in registration.
type claude struct{} type claude struct{}
func (claude) Name() string { return "claude" } func (claude) Name() string { return "claude" }
func (claude) Description() string { return "Anthropic Claude Code CLI" } func (claude) Description() string { return "Anthropic Claude Code CLI" }
func (c claude) Spawn(ctx Context, args []string) error { func (c claude) Spawn(ctx Context, args []string) error {
bin, err := LookPath("claude") return backends["claude"].spawn(c.Name(), "claude", ctx, args)
if err != nil {
return err
}
mcpPath, err := mcp.Render(c.Name(), ctx.Servers)
if err != nil {
return err
}
argv := c.buildArgv(bin, mcpPath, args)
env := BuildEnv([]string{"ANTHROPIC_API_KEY"}, nil)
return DefaultExecer.Exec(bin, argv, env)
}
func (claude) buildArgv(bin, mcpPath string, userArgs []string) []string {
argv := []string{bin, "--mcp-config", mcpPath}
argv = append(argv, userArgs...)
return argv
} }
+4 -20
View File
@@ -1,9 +1,5 @@
package agent package agent
import (
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/mcp"
)
func init() { Register(&copilot{}) } func init() { Register(&copilot{}) }
// copilot integrates the GitHub Copilot CLI (npm package `@github/copilot` // copilot integrates the GitHub Copilot CLI (npm package `@github/copilot`
@@ -17,26 +13,14 @@ func init() { Register(&copilot{}) }
// any `--additional-mcp-config` files on top. The merge is additive, // any `--additional-mcp-config` files on top. The merge is additive,
// so sherlock-injected MCPs coexist with whatever the operator has // so sherlock-injected MCPs coexist with whatever the operator has
// configured globally. // configured globally.
//
// The per-CLI quirks (flag shape, env scrubbing) live in the shared
// "copilot" backend; this type is just the built-in registration.
type copilot struct{} type copilot struct{}
func (copilot) Name() string { return "copilot" } func (copilot) Name() string { return "copilot" }
func (copilot) Description() string { return "GitHub Copilot CLI" } func (copilot) Description() string { return "GitHub Copilot CLI" }
func (c copilot) Spawn(ctx Context, args []string) error { func (c copilot) Spawn(ctx Context, args []string) error {
bin, err := LookPath("copilot") return backends["copilot"].spawn(c.Name(), "copilot", ctx, args)
if err != nil {
return err
}
mcpPath, err := mcp.Render(c.Name(), ctx.Servers)
if err != nil {
return err
}
argv := c.buildArgv(bin, mcpPath, args)
return DefaultExecer.Exec(bin, argv, BuildEnv(nil, nil))
}
func (copilot) buildArgv(bin, mcpPath string, userArgs []string) []string {
argv := []string{bin, "--additional-mcp-config", "@" + mcpPath}
argv = append(argv, userArgs...)
return argv
} }
+56
View File
@@ -0,0 +1,56 @@
package agent
import "fmt"
// custom is an operator-defined agent declared in config.toml's
// `[agents.<name>]` table. It wraps an arbitrary command — typically a
// shell wrapper such as `copilot-local` that points a CLI at a
// self-hosted model — and reuses a built-in backend's MCP-config and
// env conventions so MCPs are formatted exactly as the underlying CLI
// expects.
type custom struct {
name string
desc string
command string
backend backend
}
func (c custom) Name() string { return c.name }
func (c custom) Description() string { return c.desc }
func (c custom) Spawn(ctx Context, args []string) error {
return c.backend.spawn(c.name, c.command, ctx, args)
}
// RegisterCustom adds an operator-defined agent to the registry.
//
// - name is the subcommand: `sherlock <name>`.
// - command is the binary to exec; it defaults to name when empty.
// - backendName selects the MCP/-env conventions ("copilot" or
// "claude").
//
// Unlike Register, it returns an error (rather than panicking) on a bad
// backend or a name that collides with an already-registered agent,
// since the input comes from operator config rather than program code.
func RegisterCustom(name, command, backendName string) error {
if name == "" {
return fmt.Errorf("agent: custom agent needs a name")
}
b, err := resolveBackend(backendName)
if err != nil {
return fmt.Errorf("agent: custom agent %q: %w", name, err)
}
if command == "" {
command = name
}
if _, dup := registry[name]; dup {
return fmt.Errorf("agent: custom agent %q conflicts with an existing agent", name)
}
registry[name] = custom{
name: name,
desc: fmt.Sprintf("custom %s agent (%s)", backendName, command),
command: command,
backend: b,
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package agent
import (
"os"
"slices"
"strings"
"testing"
)
func TestRegisterCustom_CopilotBackendSpawn(t *testing.T) {
fakeBin := makeFakeBinary(t, "copilot-local")
t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH"))
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
if err := RegisterCustom("copilot-local", "copilot-local", "copilot"); err != nil {
t.Fatalf("RegisterCustom: %v", err)
}
t.Cleanup(func() { delete(registry, "copilot-local") })
a, ok := Get("copilot-local")
if !ok {
t.Fatal("custom agent not registered")
}
orig := DefaultExecer
defer func() { DefaultExecer = orig }()
rec := &recordExecer{}
DefaultExecer = rec
if err := a.Spawn(Context{}, []string{"-p", "hi"}); err != nil {
t.Fatalf("Spawn: %v", err)
}
if !strings.HasSuffix(rec.argv0, "/copilot-local") {
t.Fatalf("argv0 = %s", rec.argv0)
}
// Copilot backend formatting: --additional-mcp-config @<path>.
if !slices.Contains(rec.argv, "--additional-mcp-config") {
t.Fatalf("argv missing copilot mcp flag: %v", rec.argv)
}
foundAt := false
for _, a := range rec.argv {
if strings.HasPrefix(a, "@") && strings.HasSuffix(a, "/copilot-local.mcp.json") {
foundAt = true
}
}
if !foundAt {
t.Fatalf("argv missing @-prefixed mcp path named after the custom agent: %v", rec.argv)
}
}
func TestRegisterCustom_ClaudeBackendForbidsKeyAndDefaultsCommand(t *testing.T) {
fakeBin := makeFakeBinary(t, "claude-local")
t.Setenv("PATH", fakeBin+":"+os.Getenv("PATH"))
t.Setenv("ANTHROPIC_API_KEY", "leaked")
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
// Empty command must default to the agent name.
if err := RegisterCustom("claude-local", "", "claude"); err != nil {
t.Fatalf("RegisterCustom: %v", err)
}
t.Cleanup(func() { delete(registry, "claude-local") })
orig := DefaultExecer
defer func() { DefaultExecer = orig }()
rec := &recordExecer{}
DefaultExecer = rec
a, _ := Get("claude-local")
if err := a.Spawn(Context{}, nil); err != nil {
t.Fatalf("Spawn: %v", err)
}
if !strings.HasSuffix(rec.argv0, "/claude-local") {
t.Fatalf("argv0 should default to the agent name: %s", rec.argv0)
}
if !slices.Contains(rec.argv, "--mcp-config") {
t.Fatalf("argv missing claude mcp flag: %v", rec.argv)
}
if hasEnv(rec.env, "ANTHROPIC_API_KEY") {
t.Fatal("ANTHROPIC_API_KEY leaked to claude-backed custom agent")
}
}
func TestRegisterCustom_UnknownBackend(t *testing.T) {
err := RegisterCustom("weird", "weird", "gemini")
if err == nil {
t.Fatal("expected error for unknown backend")
}
if !strings.Contains(err.Error(), "gemini") {
t.Fatalf("error should mention the bad backend: %v", err)
}
if _, ok := Get("weird"); ok {
t.Fatal("agent with bad backend should not be registered")
}
}
func TestRegisterCustom_NameCollision(t *testing.T) {
// "copilot" is a built-in; a custom agent must not clobber it.
if err := RegisterCustom("copilot", "copilot-local", "copilot"); err == nil {
t.Fatal("expected error on collision with built-in agent")
}
}
func TestRegisterCustom_EmptyName(t *testing.T) {
if err := RegisterCustom("", "cmd", "copilot"); err == nil {
t.Fatal("expected error for empty name")
}
}
+9
View File
@@ -205,6 +205,7 @@ func exchangeAndVerify(ctx context.Context, cfg Config, provider *oidc.Provider,
AccessToken: tok.AccessToken, AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken, RefreshToken: tok.RefreshToken,
IDExpiresAt: idTok.Expiry, IDExpiresAt: idTok.Expiry,
AccessExpAt: accessExpiry(tok.Expiry, idTok.Expiry),
RefreshExpAt: refreshExpiry(tok), RefreshExpAt: refreshExpiry(tok),
Issuer: cfg.Issuer, Issuer: cfg.Issuer,
ClientID: cfg.ClientID, ClientID: cfg.ClientID,
@@ -239,12 +240,20 @@ func splitScope(s string) []string {
if i > start { if i > start {
out = append(out, s[start:i]) out = append(out, s[start:i])
} }
start = i + 1 start = i + 1
} }
} }
return out 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 // refreshExpiry pulls Authentik's `refresh_expires_in` (an extension
// to RFC 6749). If absent we conservatively assume 30 days, matching // to RFC 6749). If absent we conservatively assume 30 days, matching
// Authentik's default. // Authentik's default.
+3
View File
@@ -246,6 +246,9 @@ func TestLogin_HappyPath(t *testing.T) {
if res.Tokens.ClientID != "test-client" { if res.Tokens.ClientID != "test-client" {
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID) 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:") { if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
t.Fatalf("Issuer = %q", res.Tokens.Issuer) t.Fatalf("Issuer = %q", res.Tokens.Issuer)
} }
+16 -5
View File
@@ -17,8 +17,8 @@ import (
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring" "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
) )
// RefreshSkew is how much headroom EnsureFresh leaves before the ID // RefreshSkew is how much headroom EnsureFresh leaves before the
// token actually expires. Refreshing 30s ahead of expiry avoids // bearer token actually expires. Refreshing 30s ahead of expiry avoids
// shipping a token that will be rejected by clock-skewed resource // shipping a token that will be rejected by clock-skewed resource
// servers. // servers.
const RefreshSkew = 30 * time.Second const RefreshSkew = 30 * time.Second
@@ -39,7 +39,7 @@ type EnsureFreshOptions struct {
} }
// EnsureFresh returns the stored TokenSet for service, refreshing it // 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, // Cross-process safe: takes an exclusive flock on opts.LockPath,
// re-reads the keyring after acquiring the lock, and skips the // re-reads the keyring after acquiring the lock, and skips the
// refresh if a concurrent process already rotated the tokens. // 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() { if ts.Empty() {
return ts, keyring.ErrNoTokens return ts, keyring.ErrNoTokens
} }
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) { if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil return ts, nil
} }
@@ -93,7 +93,7 @@ func EnsureFresh(ctx context.Context, store keyring.Store, service string, opts
if err != nil { if err != nil {
return ts, err return ts, err
} }
if opts.Clock().Add(RefreshSkew).Before(ts.IDExpiresAt) { if opts.Clock().Add(RefreshSkew).Before(expiryForRefresh(ts)) {
return ts, nil return ts, nil
} }
if ts.RefreshToken == "" { if ts.RefreshToken == "" {
@@ -151,7 +151,18 @@ func refreshOnce(ctx context.Context, ts keyring.TokenSet, disc Discoverer) (key
out.RefreshToken = tok.RefreshToken out.RefreshToken = tok.RefreshToken
} }
out.IDExpiresAt = idTok.Expiry out.IDExpiresAt = idTok.Expiry
out.AccessExpAt = accessExpiry(tok.Expiry, idTok.Expiry)
out.RefreshExpAt = refreshExpiry(tok) out.RefreshExpAt = refreshExpiry(tok)
out.Scopes = grantedScopes(tok, ts.Scopes) out.Scopes = grantedScopes(tok, ts.Scopes)
return out, nil return out, nil
} }
func expiryForRefresh(ts keyring.TokenSet) time.Time {
if !ts.AccessExpAt.IsZero() {
return ts.AccessExpAt
}
if ts.AccessToken != "" && ts.RefreshToken != "" {
return time.Time{}
}
return ts.IDExpiresAt
}
+2 -2
View File
@@ -197,7 +197,7 @@ func (s *TokenSource) Run(ctx context.Context) error {
} }
// nextWait computes how long to sleep before the next proactive // 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. // a very short TTL doesn't spin.
func (s *TokenSource) nextWait() time.Duration { func (s *TokenSource) nextWait() time.Duration {
s.mu.Lock() s.mu.Lock()
@@ -206,7 +206,7 @@ func (s *TokenSource) nextWait() time.Duration {
if cur.Empty() { if cur.Empty() {
return s.minWait return s.minWait
} }
wait := cur.IDExpiresAt.Sub(s.clock()) - s.skew wait := expiryForRefresh(cur).Sub(s.clock()) - s.skew
if wait < s.minWait { if wait < s.minWait {
return s.minWait return s.minWait
} }
+9 -3
View File
@@ -73,13 +73,16 @@ func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) { func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
stub := newStubAuthentik(t) stub := newStubAuthentik(t)
store := fakekeyring.New() store := fakekeyring.New()
// Seed a STALE token (already expired) with a refresh token so the // Seed a stale bearer with a still-fresh ID token. This mirrors
// renewal path fires immediately. // 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{ _ = store.Set("gitea", keyring.TokenSet{
IDToken: "old", IDToken: "old",
AccessToken: "at-stale", AccessToken: "at-stale",
RefreshToken: "rt-1", 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, Issuer: stub.srv.URL,
ClientID: "test-client", ClientID: "test-client",
Scopes: []string{"openid"}, Scopes: []string{"openid"},
@@ -114,6 +117,9 @@ func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
if stored.AccessToken != "at-refreshed" { if stored.AccessToken != "at-refreshed" {
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken) 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() cancel()
select { select {
+23 -7
View File
@@ -7,6 +7,7 @@ package browser
import ( import (
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
) )
// Open starts the OS's default browser pointed at url, then returns // 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 // itself reports — there's no portable way to know whether the user
// actually loaded the page. // actually loaded the page.
func Open(url string) error { 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 bin string
var args []string var args []string
switch runtime.GOOS { switch goos {
case "darwin": case "darwin":
bin = "open" bin = "open"
args = []string{url} args = []string{url}
@@ -28,10 +49,5 @@ func Open(url string) error {
bin = "xdg-open" bin = "xdg-open"
args = []string{url} args = []string{url}
} }
cmd := exec.Command(bin, args...) return bin, args
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
} }
+36
View File
@@ -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)
}
}
+32
View File
@@ -9,6 +9,9 @@
// //
// Shape: // Shape:
// //
// [oauth]
// browser = "firefox" # optional; default OS browser when empty
//
// [providers.sherlock-cli] # a reusable OAuth provider // [providers.sherlock-cli] # a reusable OAuth provider
// issuer = "https://id.example/application/o/sherlock-cli/" // issuer = "https://id.example/application/o/sherlock-cli/"
// client_id = "abc123" // client_id = "abc123"
@@ -22,6 +25,10 @@
// provider = "sherlock-cli" // provider = "sherlock-cli"
// base_url = "https://grafana.example" // base_url = "https://grafana.example"
// //
// [agents.copilot-local] # a custom wrapper agent
// command = "copilot-local" # binary to exec (defaults to name)
// backend = "copilot" # MCP/-env conventions to follow
//
// A service either references a [providers.<name>] block via `provider` // A service either references a [providers.<name>] block via `provider`
// or carries its own inline issuer/client_id (as gitea does, since it // or carries its own inline issuer/client_id (as gitea does, since it
// authenticates against its own OAuth server rather than Authentik). // authenticates against its own OAuth server rather than Authentik).
@@ -66,10 +73,33 @@ type Service struct {
BaseURL string `toml:"base_url"` 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"`
}
// Agent is an operator-defined custom agent: a wrapper command plus the
// backend whose MCP-config and env conventions it follows. It lets an
// operator expose, say, a `copilot-local` wrapper (which points the
// Copilot CLI at a self-hosted model) as `sherlock copilot-local` while
// reusing the built-in "copilot" backend's MCP formatting.
//
// Command is the binary sherlock execs; it defaults to the agent's name
// when empty. Backend selects the MCP/-env conventions and must name a
// backend the agent package knows ("copilot" or "claude").
type Agent struct {
Command string `toml:"command"`
Backend string `toml:"backend"`
}
// Config is the whole parsed file. // Config is the whole parsed file.
type Config struct { type Config struct {
OAuth OAuth `toml:"oauth"`
Providers map[string]Provider `toml:"providers"` Providers map[string]Provider `toml:"providers"`
Services map[string]Service `toml:"services"` Services map[string]Service `toml:"services"`
Agents map[string]Agent `toml:"agents"`
// path is where this config was loaded from, for error messages. // path is where this config was loaded from, for error messages.
path string path string
@@ -83,6 +113,7 @@ type Resolved struct {
ClientID string ClientID string
ClientSecret string ClientSecret string
BaseURL string BaseURL string
OAuthBrowser string
} }
// DefaultPath returns the config-file path sherlock reads, honouring // DefaultPath returns the config-file path sherlock reads, honouring
@@ -155,6 +186,7 @@ func (c *Config) Service(name string) (Resolved, error) {
ClientID: svc.ClientID, ClientID: svc.ClientID,
ClientSecret: svc.ClientSecret, ClientSecret: svc.ClientSecret,
BaseURL: svc.BaseURL, BaseURL: svc.BaseURL,
OAuthBrowser: c.OAuth.Browser,
} }
if svc.Provider != "" { if svc.Provider != "" {
+64
View File
@@ -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) { func TestService_UnknownService(t *testing.T) {
path := writeConfig(t, ` path := writeConfig(t, `
[services.gitea] [services.gitea]
@@ -164,3 +184,47 @@ func TestDefaultPath_XDG(t *testing.T) {
t.Fatalf("DefaultPath = %q", got) t.Fatalf("DefaultPath = %q", got)
} }
} }
func TestAgents_Parsed(t *testing.T) {
path := writeConfig(t, `
[agents.copilot-local]
command = "copilot-local"
backend = "copilot"
[agents.claude-local]
backend = "claude"
`)
c, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom: %v", err)
}
if len(c.Agents) != 2 {
t.Fatalf("expected 2 agents, got %d: %+v", len(c.Agents), c.Agents)
}
cp := c.Agents["copilot-local"]
if cp.Command != "copilot-local" || cp.Backend != "copilot" {
t.Fatalf("copilot-local = %+v", cp)
}
// command is optional; absence leaves it empty for the agent layer
// to default to the name.
cl := c.Agents["claude-local"]
if cl.Command != "" || cl.Backend != "claude" {
t.Fatalf("claude-local = %+v", cl)
}
}
func TestAgents_AbsentIsEmpty(t *testing.T) {
path := writeConfig(t, `
[services.gitea]
issuer = "https://gitea.example"
client_id = "x"
base_url = "https://gitea.example"
`)
c, err := LoadFrom(path)
if err != nil {
t.Fatalf("LoadFrom: %v", err)
}
if len(c.Agents) != 0 {
t.Fatalf("expected no agents, got %+v", c.Agents)
}
}
+108
View File
@@ -53,6 +53,114 @@ func seedConfig(opts *Options, example, cfgPath string) error {
return os.WriteFile(cfgPath, data, 0o600) 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 // pickEditor resolves the editor command: $VISUAL, then $EDITOR, then
// the first of sensible-editor/nano/vi found on PATH. Returns "" if none. // the first of sensible-editor/nano/vi found on PATH. Returns "" if none.
func pickEditor() string { func pickEditor() string {
+19 -6
View File
@@ -31,7 +31,7 @@ const devVersion = "0.0.0-dev"
// binaries are the cmd/<name> packages installed. `go install ./cmd/...` // binaries are the cmd/<name> packages installed. `go install ./cmd/...`
// covers exactly these (the setup entry lives outside cmd/, so it is // covers exactly these (the setup entry lives outside cmd/, so it is
// never installed). // 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. // Options controls one Install run.
type Options struct { type Options struct {
@@ -49,7 +49,8 @@ type Options struct {
// SeedConfig copies config.example.toml to the operator's config // SeedConfig copies config.example.toml to the operator's config
// path when none exists yet (never clobbers a filled-in file). // path when none exists yet (never clobbers a filled-in file).
SeedConfig bool 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 OpenEditor bool
// ConfigOnly stops after the config step (no build/install). // ConfigOnly stops after the config step (no build/install).
ConfigOnly bool 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) 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 { if opts.SeedConfig {
cfgPath, err := config.DefaultPath()
if err != nil {
return err
}
if err := seedConfig(&opts, example, cfgPath); err != nil { if err := seedConfig(&opts, example, cfgPath); err != nil {
return err 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 opts.OpenEditor {
if err := openEditor(&opts, cfgPath); err != nil { if err := openEditor(&opts, cfgPath); err != nil {
return err return err
+63
View File
@@ -3,6 +3,7 @@ package installer
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "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) { func TestFishCompletionsDir(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("__fish_config_dir", "") t.Setenv("__fish_config_dir", "")
+1
View File
@@ -139,6 +139,7 @@ type TokenSet struct {
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
AccessToken string `json:"access_token,omitempty"` AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"`
AccessExpAt time.Time `json:"access_expires_at,omitempty"`
IDExpiresAt time.Time `json:"id_expires_at"` IDExpiresAt time.Time `json:"id_expires_at"`
RefreshExpAt time.Time `json:"refresh_expires_at"` RefreshExpAt time.Time `json:"refresh_expires_at"`
Issuer string `json:"issuer"` Issuer string `json:"issuer"`