Synced from monorepo
Synced from monorepo Changes: - Refresh tool search when the managed MCP catalog is re-fetched - Prevent duplicate leader process spawn and startup hang from stale leaders - Document marketplaces, plugins, and organization controls - Stamp session ID on image generation direct-to-API requests - Fix auto mode blocked documentation - Auto mode considers recent user intent - Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API - Fail-closed auth refresh contract for shell clients - Emit a chat-supplied per-session turn index in turn hooks - Show bash mode chrome in minimal mode - Add metrics for true-noop and stationarity stops - Include voice interim text on prompt submit - Silently end turn on true-noop thrash - Quiet copy toast when clipboard delivery is confirmed - Fix session fork truncating at the wrong prompt in rewound sessions - Make the idle "still running" watcher cue clickable to open the tasks pane - Default web search model to grok-4.5 - Let plugin subagents inherit parent MCP servers - Gate no-op end-turn reminder on system reminders - Add gateway bridge lifecycle telemetry - Allow editing finalized text while voice is open - Relocate token carrier to turn-commit events and plumb per-turn origin context - Raise workflow scratch quotas and make failed runs resumable - Workflows overlay: auto-progress phases, live agent status, and drop budget meter Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
This commit is contained in:
parent
69f0ba880a
commit
6e38642082
103 changed files with 4964 additions and 1261 deletions
|
|
@ -159,6 +159,15 @@ pub struct PrincipalKey {
|
|||
fingerprint: String,
|
||||
}
|
||||
|
||||
impl PrincipalKey {
|
||||
/// Stable non-secret fingerprint (e.g. OIDC issuer+client); never tokens.
|
||||
pub fn opaque(fingerprint: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fingerprint: fingerprint.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PrincipalKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PrincipalKey").finish_non_exhaustive()
|
||||
|
|
|
|||
|
|
@ -467,6 +467,79 @@ mod inner {
|
|||
pub(crate) fn admission_wait_observe(secs: f64) {
|
||||
ADMISSION_WAIT_SECONDS.observe(secs);
|
||||
}
|
||||
|
||||
// ── OIDC refresh (auth.current path) ────────────────────────────
|
||||
|
||||
/// Closed-set outcomes for `AuthProvider::current` (metric labels).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OidcRefreshOutcome {
|
||||
SkippedNotExpired,
|
||||
Ok,
|
||||
FailedUsedStale,
|
||||
}
|
||||
|
||||
impl OidcRefreshOutcome {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SkippedNotExpired => "skipped_not_expired",
|
||||
Self::Ok => "ok",
|
||||
Self::FailedUsedStale => "failed_used_stale",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static OIDC_REFRESH_TOTAL: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"computer_hub_oidc_refresh_total",
|
||||
"OIDC AuthProvider::current outcomes: skipped_not_expired (no network), \
|
||||
ok (refresh succeeded), failed_used_stale (refresh failed, stale token returned).",
|
||||
&["outcome"]
|
||||
)
|
||||
.expect("computer_hub_oidc_refresh_total must register once")
|
||||
});
|
||||
|
||||
static OIDC_REFRESH_DURATION_SECONDS: LazyLock<Histogram> = LazyLock::new(|| {
|
||||
register_histogram!(
|
||||
"computer_hub_oidc_refresh_duration_seconds",
|
||||
"Wall-clock time of an attempted OIDC refresh (discovery + token exchange). \
|
||||
Not sampled for skipped_not_expired.",
|
||||
exponential_buckets(0.01, 2.0, 14).expect("valid bucket params")
|
||||
)
|
||||
.expect("computer_hub_oidc_refresh_duration_seconds must register once")
|
||||
});
|
||||
|
||||
/// Duration observed only for attempted refreshes (`Ok` / `FailedUsedStale`).
|
||||
pub(crate) fn oidc_refresh_observe(outcome: OidcRefreshOutcome, secs: Option<f64>) {
|
||||
OIDC_REFRESH_TOTAL
|
||||
.with_label_values(&[outcome.as_str()])
|
||||
.inc();
|
||||
if let Some(secs) = secs {
|
||||
OIDC_REFRESH_DURATION_SECONDS.observe(secs);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn oidc_refresh_count(outcome: OidcRefreshOutcome) -> u64 {
|
||||
OIDC_REFRESH_TOTAL
|
||||
.with_label_values(&[outcome.as_str()])
|
||||
.get()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn oidc_refresh_duration_sample_count() -> u64 {
|
||||
OIDC_REFRESH_DURATION_SECONDS.get_sample_count()
|
||||
}
|
||||
|
||||
/// Serializes OIDC metric delta assertions under parallel `cargo test`.
|
||||
#[cfg(test)]
|
||||
static OIDC_METRICS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_oidc_metrics_test() -> std::sync::MutexGuard<'static, ()> {
|
||||
OIDC_METRICS_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "metrics"))]
|
||||
|
|
@ -507,8 +580,16 @@ mod inner {
|
|||
pub(crate) fn tool_call_inflight_inc(_scope: &str) {}
|
||||
pub(crate) fn tool_call_inflight_dec(_scope: &str) {}
|
||||
pub(crate) fn admission_wait_observe(_secs: f64) {}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OidcRefreshOutcome {
|
||||
SkippedNotExpired,
|
||||
Ok,
|
||||
FailedUsedStale,
|
||||
}
|
||||
pub(crate) fn oidc_refresh_observe(_outcome: OidcRefreshOutcome, _secs: Option<f64>) {}
|
||||
}
|
||||
|
||||
pub(crate) use inner::OidcRefreshOutcome;
|
||||
pub(crate) use inner::admission_wait_observe;
|
||||
pub(crate) use inner::call_dispatch_observe;
|
||||
pub(crate) use inner::call_id_collision;
|
||||
|
|
@ -524,8 +605,15 @@ pub(crate) use inner::inbox_full_notification_dropped;
|
|||
pub(crate) use inner::inbox_full_reject_send_failed;
|
||||
pub(crate) use inner::inbox_full_request_rejected;
|
||||
pub(crate) use inner::liveness_deadline_expired;
|
||||
#[cfg(all(test, feature = "metrics"))]
|
||||
pub(crate) use inner::lock_oidc_metrics_test;
|
||||
pub(crate) use inner::no_handler;
|
||||
pub(crate) use inner::notif_lagged_recovered;
|
||||
#[cfg(all(test, feature = "metrics"))]
|
||||
pub(crate) use inner::oidc_refresh_count;
|
||||
#[cfg(all(test, feature = "metrics"))]
|
||||
pub(crate) use inner::oidc_refresh_duration_sample_count;
|
||||
pub(crate) use inner::oidc_refresh_observe;
|
||||
pub(crate) use inner::pool_connections_dec;
|
||||
pub(crate) use inner::pool_connections_inc;
|
||||
pub(crate) use inner::pool_evictions_inc;
|
||||
|
|
|
|||
|
|
@ -131,13 +131,46 @@ impl AuthProvider for OidcAuthProvider {
|
|||
Utc::now() + chrono::Duration::from_std(REFRESH_MARGIN).unwrap() >= exp
|
||||
})
|
||||
};
|
||||
if expired && let Err(e) = self.try_refresh() {
|
||||
tracing::warn!(error = %e, "OIDC refresh failed, using stale token");
|
||||
if !expired {
|
||||
crate::metrics::oidc_refresh_observe(
|
||||
crate::metrics::OidcRefreshOutcome::SkippedNotExpired,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
use crate::metrics::{OidcRefreshOutcome, oidc_refresh_observe};
|
||||
let started = std::time::Instant::now();
|
||||
match self.try_refresh() {
|
||||
Ok(()) => {
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
oidc_refresh_observe(OidcRefreshOutcome::Ok, Some(secs));
|
||||
tracing::info!(duration_secs = secs, outcome = "ok", "OIDC token refreshed");
|
||||
}
|
||||
Err(e) => {
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
oidc_refresh_observe(OidcRefreshOutcome::FailedUsedStale, Some(secs));
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
duration_secs = secs,
|
||||
outcome = "failed_used_stale",
|
||||
"OIDC refresh failed, using stale token"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let s = self.state.lock();
|
||||
AuthCredential::bearer(&s.access_token)
|
||||
}
|
||||
|
||||
/// Stable issuer/client/user pool key; does not call [`Self::current`].
|
||||
fn principal_key(&self) -> crate::auth::PrincipalKey {
|
||||
let mut fingerprint = format!("oidc:{}:{}", self.issuer, self.client_id);
|
||||
if let Some(uid) = self.user_id.as_deref() {
|
||||
fingerprint.push(':');
|
||||
fingerprint.push_str(uid);
|
||||
}
|
||||
crate::auth::PrincipalKey::opaque(fingerprint)
|
||||
}
|
||||
|
||||
/// Surface the principal fields parsed from the auth source. `None` only
|
||||
/// when no `user_id` was supplied (nothing to attribute).
|
||||
fn identity(&self) -> Option<AuthIdentity> {
|
||||
|
|
@ -219,8 +252,6 @@ impl OidcAuthProvider {
|
|||
.expires_in
|
||||
.map(|s| Utc::now() + chrono::Duration::seconds(s as i64));
|
||||
|
||||
tracing::info!(expires_at = ?expires_at, "OIDC token refreshed");
|
||||
|
||||
if let Some(ref cb) = self.on_refresh {
|
||||
cb(&RefreshEvent {
|
||||
access_token: tokens.access_token.clone(),
|
||||
|
|
@ -245,6 +276,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn current_returns_token_when_not_expired() {
|
||||
#[cfg(feature = "metrics")]
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
let provider = OidcAuthProviderBuilder::new(
|
||||
"access-tok",
|
||||
"refresh-tok",
|
||||
|
|
@ -265,6 +298,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn current_returns_token_when_no_expiry() {
|
||||
#[cfg(feature = "metrics")]
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
let provider = OidcAuthProviderBuilder::new(
|
||||
"no-expiry-tok",
|
||||
"refresh-tok",
|
||||
|
|
@ -282,6 +317,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn current_returns_stale_token_when_refresh_fails() {
|
||||
#[cfg(feature = "metrics")]
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
// Expired token, but issuer is unreachable — should return stale
|
||||
let provider = OidcAuthProviderBuilder::new(
|
||||
"stale-tok",
|
||||
|
|
@ -299,6 +336,162 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
#[test]
|
||||
fn current_records_skipped_and_failed_refresh_outcomes() {
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
use crate::metrics::OidcRefreshOutcome;
|
||||
let skipped_before =
|
||||
crate::metrics::oidc_refresh_count(OidcRefreshOutcome::SkippedNotExpired);
|
||||
let failed_before = crate::metrics::oidc_refresh_count(OidcRefreshOutcome::FailedUsedStale);
|
||||
let duration_before = crate::metrics::oidc_refresh_duration_sample_count();
|
||||
|
||||
let fresh = OidcAuthProviderBuilder::new(
|
||||
"access-tok",
|
||||
"refresh-tok",
|
||||
"https://auth.example.com",
|
||||
"client1",
|
||||
)
|
||||
.expires_at(Utc::now() + chrono::Duration::hours(1))
|
||||
.build();
|
||||
let _ = fresh.current();
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_count(OidcRefreshOutcome::SkippedNotExpired),
|
||||
skipped_before + 1
|
||||
);
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_duration_sample_count(),
|
||||
duration_before,
|
||||
"skipped path must not observe refresh duration"
|
||||
);
|
||||
|
||||
let stale = OidcAuthProviderBuilder::new(
|
||||
"stale-tok",
|
||||
"refresh-tok",
|
||||
"https://localhost:1",
|
||||
"client1",
|
||||
)
|
||||
.expires_at(Utc::now() - chrono::Duration::hours(1))
|
||||
.build();
|
||||
let _ = stale.current();
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_count(OidcRefreshOutcome::FailedUsedStale),
|
||||
failed_before + 1
|
||||
);
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_duration_sample_count(),
|
||||
duration_before + 1,
|
||||
"failed refresh must observe exactly one duration sample"
|
||||
);
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_count(OidcRefreshOutcome::SkippedNotExpired),
|
||||
skipped_before + 1,
|
||||
"failed refresh must not also count as skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn principal_key_is_stable_and_does_not_call_current() {
|
||||
#[cfg(feature = "metrics")]
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
#[cfg(feature = "metrics")]
|
||||
let skipped_before = crate::metrics::oidc_refresh_count(
|
||||
crate::metrics::OidcRefreshOutcome::SkippedNotExpired,
|
||||
);
|
||||
|
||||
let provider = OidcAuthProviderBuilder::new(
|
||||
"access-tok",
|
||||
"refresh-tok",
|
||||
"https://auth.example.com",
|
||||
"client1",
|
||||
)
|
||||
.user_id("user-9")
|
||||
.expires_at(Utc::now() + chrono::Duration::hours(1))
|
||||
.build();
|
||||
|
||||
let k1 = provider.principal_key();
|
||||
let k2 = provider.principal_key();
|
||||
assert_eq!(k1, k2);
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_count(
|
||||
crate::metrics::OidcRefreshOutcome::SkippedNotExpired
|
||||
),
|
||||
skipped_before,
|
||||
"principal_key must not call current()"
|
||||
);
|
||||
|
||||
let token_key = AuthCredential::bearer("access-tok").principal_key();
|
||||
assert_ne!(k1, token_key);
|
||||
}
|
||||
|
||||
#[cfg(feature = "metrics")]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn current_records_ok_refresh_outcome_against_mock_idp() {
|
||||
use crate::metrics::OidcRefreshOutcome;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
let _guard = crate::metrics::lock_oidc_metrics_test();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base = format!("http://{addr}");
|
||||
let token_endpoint = format!("{base}/token");
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
get(move || {
|
||||
let token_endpoint = token_endpoint.clone();
|
||||
async move {
|
||||
axum::Json(serde_json::json!({
|
||||
"token_endpoint": token_endpoint
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/token",
|
||||
post(|| async {
|
||||
axum::Json(serde_json::json!({
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"expires_in": 3600
|
||||
}))
|
||||
}),
|
||||
);
|
||||
let _server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
let ok_before = crate::metrics::oidc_refresh_count(OidcRefreshOutcome::Ok);
|
||||
let duration_before = crate::metrics::oidc_refresh_duration_sample_count();
|
||||
|
||||
let provider = OidcAuthProviderBuilder::new("stale-access", "refresh-tok", base, "client1")
|
||||
.expires_at(Utc::now() - chrono::Duration::hours(1))
|
||||
.build();
|
||||
|
||||
// try_refresh uses block_in_place; needs multi-thread runtime.
|
||||
let cred = tokio::task::spawn_blocking(move || provider.current())
|
||||
.await
|
||||
.expect("join");
|
||||
match cred {
|
||||
AuthCredential::Bearer { token } => assert_eq!(token, "fresh-access"),
|
||||
_ => panic!("expected Bearer"),
|
||||
}
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_count(OidcRefreshOutcome::Ok),
|
||||
ok_before + 1
|
||||
);
|
||||
assert_eq!(
|
||||
crate::metrics::oidc_refresh_duration_sample_count(),
|
||||
duration_before + 1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_surfaces_principal_fields() {
|
||||
let provider = OidcAuthProviderBuilder::new("tok", "rt", "https://auth.example.com", "c1")
|
||||
|
|
|
|||
Loading…
Reference in a new issue