4.2 KiB
Storage
How sherlock persists secrets and runtime state.
Decisions
| Topic | Decision |
|---|---|
| Token storage | OS keyring via github.com/zalando/go-keyring. No on-disk credential files, no age blobs, no plaintext. |
| Wallet shape | One TokenSet per service (gitea, grafana, miniflux, …). Tracked via a sidecar services-index entry so Store.List() works on every backend without OS-specific search APIs. |
| Pre-flight | The only constructor is keyring.Open(), which probes the keyring before returning a Store. There is no probe-less escape hatch. A missing/locked keyring returns *UnavailableError (with a populated Hint field) and exits 3. |
| Keyring service ns | sherlock for all real entries; sherlock-preflight for the probe sentinel. Per-service tokens are accounts of the form service:<name>; the index is account services-index. |
| Runtime files | $XDG_RUNTIME_DIR/sherlock/<agent>.mcp.json (0600), $XDG_RUNTIME_DIR/sherlock.refresh.lock (0600, flock anchor for cross-process refreshes). No socket, no PID file, no daemon log. |
| Config files | $XDG_CONFIG_HOME/sherlock/services.d/*.toml (operator-registered services, Phase 2+). Agent integrations are compiled in — see agents.md. |
TokenSet
What sherlock stores per service entry:
type TokenSet struct {
IDToken string
AccessToken string
RefreshToken string
IDExpiresAt time.Time
RefreshExpAt time.Time
Issuer string // for refresh: full OIDC issuer URL
ClientID string // for refresh: OAuth client ID
Scopes []string // for refresh: scopes to re-request
Subject string
Email string
Name string
}
Serialized as a single JSON blob per service entry — the keyring
exposes one secret per (service, account) pair and we don't want to
round-trip multiple secrets per session.
Issuer + ClientID + Scopes are persisted alongside the tokens so
refresh and re-login work from any process, with no dependency on the
shell environment that originally created the entry.
Wallet API
keyring.Store is service-keyed:
type Store interface {
Get(service string) (TokenSet, error) // ErrNoTokens if missing
Set(service string, ts TokenSet) error // also updates the index
Clear(service string) error // also updates the index
List() ([]string, error) // names sorted
}
Get / Set / Clear go straight to the OS keyring under
(service="sherlock", account="service:<name>"). List reads the
sidecar services-index entry; Set/Clear keep it in sync.
Pre-flight semantics
keyring.Open():
- Probes the keyring: writes a fixed sentinel value under service
sherlock-preflight/ accountprobe, reads it back, deletes it. - On success: returns a live
Storebacked by the OS keyring. - On failure: returns
*keyring.UnavailableErrorwhoseCausefield wraps the underlying error and whoseHintfield 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.