Skip to content

Resolverresolve and probe

Resolver walks a CredentialRef through a fixed chain of sources and returns the first hit.

Constructing a Resolver

Constructor Backend
Resolver::new(store: Arc<dyn CredentialStore>) the store supplied
Resolver::with_platform_default() KeyringStore::new()
Resolver::default() identical to with_platform_default()

All three are infallible and do no I/O. The injected store is used for the keychain leg only — the environment and literal legs do not consult it.

use rtb_credentials::{MemoryStore, Resolver};
use std::sync::Arc;

let resolver = Resolver::new(Arc::new(MemoryStore::new()));

The resolution chain

resolve returns the first source that produces a value:

  1. cref.envstd::env::var(name). A set variable is a hit, even if its value is the empty string.
  2. cref.keychainstore.get(service, account) on the injected store.
  3. cref.literal — the embedded secret, unless CI=true.
  4. cref.fallback_envstd::env::var(name).

A key that is absent is skipped. If every configured leg misses, resolve returns CredentialError::NotFound.

The order is fixed in code and there is no way to reorder, disable or extend it. A tool that needs different precedence has to implement its own walk over the four fields.

A keychain backend error aborts the walk

The keychain leg distinguishes two kinds of failure:

Store returns Resolver does
CredentialError::NotFound falls through to the next leg
any other error returns that error immediately

A locked, unavailable or misconfigured keychain therefore fails the whole resolution even when literal or fallback_env is configured and would have produced a value. Only a clean "no such entry" continues the chain.

This is why a machine with a broken keychain can fail to start a tool that appears, from its config, to have a perfectly good environment-variable fallback.

literal under CI=true stops the chain rather than falling through

When the literal key is present and the process is running under CI=true, resolve returns CredentialError::LiteralRefusedInCi.

It does not continue to fallback_env. A reference carrying both a literal and a fallback_env fails in CI even when the fallback variable is set and readable:

anthropic:
  literal: sk-ant-...        # present
  fallback_env: ANTHROPIC_API_KEY   # set in CI, but never reached

Removing the literal key from config that runs in CI is the way to make the fallback reachable.

What counts as CI

The check is exact-match, case-sensitive:

fn is_ci() -> bool {
    std::env::var("CI").as_deref() == Ok("true")
}

Only the literal string true triggers refusal. CI=1, CI=TRUE, CI=True and CI=yes all leave literals permitted. See environment variables for which platforms set the value this crate expects.

Which name appears in a NotFound error

When nothing resolves, the name field of NotFound is chosen by this order — which is not the resolution order:

  1. fallback_env, if set
  2. otherwise env, if set
  3. otherwise "{service}/{account}" from keychain, if set
  4. otherwise the literal string <unnamed credential>

A reference with both env: MYTOOL_KEY and fallback_env: ANTHROPIC_API_KEY reports credential not found: ANTHROPIC_API_KEY, naming the ecosystem variable rather than the tool's own. The message names one source, not every source that was tried.

probe reports the winning source without returning the secret

pub async fn probe(&self, cref: &CredentialRef)
    -> Result<ResolutionOutcome, CredentialError>;

probe walks the same chain and reports which leg would win.

ResolutionOutcome Meaning
Resolved(ResolutionSource) this leg produced a readable value
LiteralRefusedInCi literal is configured and CI=true
Missing nothing resolved

ResolutionSource is Env, Keychain, Literal or FallbackEnv. Both enums are #[non_exhaustive], so a match on either needs a wildcard arm and adding a variant is not a breaking change.

LiteralRefusedInCi is an Ok outcome from probe and an Err from resolve. The split is deliberate: a status command wants to display "refused in CI" as a state, not to fail.

probe does the same I/O as resolve

probe is not a cheap, side-effect-free inspection. When keychain is configured it performs a real store.get, reading the secret and dropping it. On a platform keychain that means a genuine round-trip per reference, and on some systems an unlock prompt.

What probe avoids is returning the secret to the caller, so a status command can list credentials without a secret reaching a terminal.

probe and resolve can disagree on an empty keychain entry

probe's environment legs test std::env::var(name).is_ok(), and its keychain leg tests whether get returned Ok. These are the same conditions resolve uses, so the two agree on which leg wins.

The one behaviour worth knowing is shared by both: a configured source holding an empty string counts as a hit. probe reports Resolved, and resolve returns an empty SecretString. Neither treats "set but empty" as a miss, so a credential can resolve successfully to nothing and fail later at the point of use.