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