5.6 KiB
Architecture
Condensed from ../plan.md. Read this first; jump to the plan for the longer rationale.
One-paragraph summary
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 execs 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.
Diagram
flowchart TD
user["user shell"] -->|`sherlock copilot`| sh[sherlock CLI]
sh -->|syscall.Exec| agent[copilot / claude CLI]
sh -.->|writes MCP config| mcpfile[(XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json)]
agent -.->|reads| mcpfile
agent -->|stdio| giteamcp[gitea-mcp]
agent -->|stdio| grafanamcp[grafana-mcp]
agent -->|stdio| minifluxmcp[miniflux-mcp]
giteamcp -->|authn.Ensure| wallet[(OS keyring<br/>wallet: one TokenSet per service)]
grafanamcp -->|authn.Ensure| wallet
minifluxmcp -->|authn.Ensure| wallet
sh -->|status / logout| wallet
wallet -->|OAuth PKCE flow on miss<br/>refresh on stale<br/>flock-serialised| authentik[Authentik<br/>gitea provider · grafana provider ·<br/>miniflux provider · …]
Each MCP authenticates against the Authentik provider for its own service, lazily, the first time it runs. The wallet caches the result; subsequent runs are silent until the refresh window opens. There is no master session.
Components
| Component | Lives at | Owns |
|---|---|---|
sherlock (CLI) |
cmd/sherlock/ |
The only binary the operator runs. status, logout [<service>], run, agent-name aliases (copilot, claude, …). At spawn: looks up the agent in internal/agent/, renders the per-session MCP config, execs the agent. There is no sherlock login — see 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. |
internal/authn/ |
— | OAuth/OIDC primitives + the high-level Ensure(ctx, store, service, cfg, opts) that every MCP calls at startup. See 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. |
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. |
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. |
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.lockwith the canonical "take lock → re-read → maybe refresh" pattern ininternal/authn/refresh.go. - 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 loginstep. 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
- Sherlock does not federate identities — that's Authentik's job.
- Sherlock does not issue SSH certificates — gssh already does that internally when it authenticates a WebSocket session. See gssh-integration.md.
- 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.