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