Files
sherlock/docs/storage.md
T
amacocian e6d29e9710
CI / build (push) Successful in 1m25s
Setup wallet model
2026-05-25 14:15:13 +02:00

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():

  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.