# Storage How sherlock persists secrets and runtime state. ## Decisions | Topic | Decision | |---|---| | Token storage | OS keyring via [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring). No on-disk credential files, no `age` blobs, no plaintext. | | Wallet shape | One `TokenSet` per service (`gitea`, `grafana`, `miniflux`, …). Tracked via a sidecar `services-index` entry so `Store.List()` works on every backend without OS-specific search APIs. | | Pre-flight | The only constructor is `keyring.Open()`, which probes the keyring before returning a Store. There is no probe-less escape hatch. A missing/locked keyring returns `*UnavailableError` (with a populated `Hint` field) and exits 3. | | Keyring service ns | `sherlock` for all real entries; `sherlock-preflight` for the probe sentinel. Per-service tokens are accounts of the form `service:`; the index is account `services-index`. | | Runtime files | `$XDG_RUNTIME_DIR/sherlock/.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 What sherlock stores per service entry: ```go type TokenSet struct { IDToken string AccessToken string RefreshToken string IDExpiresAt time.Time RefreshExpAt time.Time Issuer string // for refresh: full OIDC issuer URL ClientID string // for refresh: OAuth client ID Scopes []string // for refresh: scopes to re-request Subject string Email string Name string } ``` 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: ```go type Store interface { Get(service string) (TokenSet, error) // ErrNoTokens if missing Set(service string, ts TokenSet) error // also updates the index Clear(service string) error // also updates the index List() ([]string, error) // names sorted } ``` `Get` / `Set` / `Clear` go straight to the OS keyring under `(service="sherlock", account="service:")`. `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.