zeroclaw-labs/zeroclaw · error

Email OAuth2 profile is missing token set: {profile_id}

Error message

Email OAuth2 profile is missing token set: {profile_id}

What it means

Raised by get_valid_email_oauth2_token after it acquires the per-profile refresh lock and re-loads the auth store: the profile still exists but its token_set field is now None. The first (pre-lock) load saw a token set, so between the two loads another writer cleared it (e.g. profile removal/re-creation, a downgrade to a non-OAuth profile kind, or a partial store overwrite). The refresh path refuses to continue without tokens to refresh.

Source

Thrown at crates/zeroclaw-providers/src/auth/mod.rs:538

            return Ok(Some(token_set.access_token.clone()));
        }

        let Some(refresh_token) = token_set.refresh_token.clone() else {
            // No refresh token; return the (possibly expired) access token and
            // let the IMAP auth failure surface as a log event.
            return Ok(Some(token_set.access_token.clone()));
        };

        let refresh_lock = refresh_lock_for_profile(&profile_id);
        let _guard = refresh_lock.lock().await;

        // Re-load after acquiring lock to avoid duplicate refreshes.
        let data = self.store.load().await?;
        let Some(latest_profile) = data.profiles.get(&profile_id) else {
            return Ok(None);
        };
        let Some(latest_tokens) = latest_profile.token_set.as_ref() else {
            anyhow::bail!("Email OAuth2 profile is missing token set: {profile_id}");
        };
        if !latest_tokens.is_expiring_within(Duration::from_secs(SKEW_SECS)) {
            return Ok(Some(latest_tokens.access_token.clone()));
        }

        let refresh_token = latest_tokens.refresh_token.clone().unwrap_or(refresh_token);

        if let Some(remaining) = refresh_backoff_remaining(&profile_id) {
            anyhow::bail!(
                "Email OAuth2 token refresh is in backoff for {remaining}s due to previous failures"
            );
        }

        let mut refreshed = match refresh_email_access_token_with_retries(
            &self.client,
            token_url,
            client_id,
            &refresh_token,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Stop concurrent writers: only mutate email OAuth profiles via the auth store APIs (store_email_oauth2_tokens / update_profile) so token_set is never cleared by partial writes
  2. Re-run `zeroclaw auth login` for the email channel (or re-do the email OAuth2 consent flow) so the profile has a valid token_set again
  3. If another long-running zeroclaw instance shares the same state dir, stop it or give each instance its own state dir
  4. Inspect the profile in the auth store JSON and delete the broken profile entry so it is re-created cleanly on next login

Example fix

// before: overwriting the whole profile drops token_set
store.upsert_profile(AuthProfile::new_bare(alias, name), true).await?;

// after: mutate in place so the existing token_set survives
store.update_profile(&profile_id, |p| {
    p.kind = AuthProfileKind::OAuth;
    Ok(())
}).await?;
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-check the profile before asking for a token
let data = auth_store.load().await?;
if let Some(id) = select_profile_id(&data, alias, None) {
    let ok = data.profiles.get(&id)
        .and_then(|p| p.token_set.as_ref())
        .is_some();
    anyhow::ensure!(ok, "profile {id} has no token set; re-run email OAuth login");
}

Try / catch

match auth.get_valid_email_oauth2_token(alias, None, url, id, &scopes).await {
    Err(e) if e.to_string().contains("missing token set") => {
        // treat as stale profile: trigger re-login flow, do not retry the call
        start_email_oauth_login(alias).await?;
    }
    result => result?,
}

Prevention

When it happens

Trigger: Two concurrent get_valid_email_oauth2_token calls for the same channel alias racing with a store update that writes profile.token_set = None; an external process (zeroclaw auth logout / profile edit) mutating the same auth store JSON while an IMAP connect is refreshing; a profile replaced with a bare profile that has no token_set.

Common situations: Running multiple channel workers against one state dir, re-importing or resetting a profile while email polling is live, or a hand-edited/corrupted auth store where a profile object lost its token_set key.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/1d940bd5035a239f. Report an issue: GitHub.