Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -87,6 +87,24 @@ impl AuthCredentialProvider for ShellAuthCredentialProvider {
|
|||
self.static_credentials.deployment_key.is_none()
|
||||
}
|
||||
}
|
||||
/// Resolves the embedding credentials for `embed_base_url`, attaching the xAI
|
||||
/// session credential only to xAI-operated endpoints over `https`.
|
||||
pub(crate) fn embedding_session_credentials(
|
||||
embed_base_url: &str,
|
||||
auth_manager: Option<&Arc<AuthManager>>,
|
||||
api_key_provider: Option<xai_grok_tools::types::SharedApiKeyProvider>,
|
||||
) -> xai_grok_memory::EndpointScopedCredentials {
|
||||
let auth_credentials = auth_manager.map(|am| {
|
||||
Arc::new(ShellAuthCredentialProvider::new(am.clone(), None, None))
|
||||
as Arc<dyn AuthCredentialProvider>
|
||||
});
|
||||
xai_grok_memory::EndpointScopedCredentials::for_endpoint(
|
||||
embed_base_url,
|
||||
crate::util::is_xai_api_bearer_url,
|
||||
auth_credentials,
|
||||
api_key_provider,
|
||||
)
|
||||
}
|
||||
/// Build a `StorageClient` for proxy uploads (including the high-volume
|
||||
/// `batch_upload` used for repo context / `repo_changes_dedup`).
|
||||
///
|
||||
|
|
@ -568,6 +586,31 @@ mod tests {
|
|||
"snapshot must reflect refreshed token for subsequent apply() calls"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn embedding_session_credentials_scopes_to_first_party() {
|
||||
let _guard = EarlyInvalidationGuard::pin_to_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = make_manager(
|
||||
&dir,
|
||||
Some(make_auth("xai-session-token", ChronoDuration::hours(1))),
|
||||
);
|
||||
let api_key_provider: xai_grok_tools::types::SharedApiKeyProvider =
|
||||
Arc::new(crate::auth::manager::SharedAuthKeyProvider(mgr.clone()));
|
||||
for denied in ["https://byok.attacker.example/v1", "http://api.x.ai/v1"] {
|
||||
let resolved =
|
||||
embedding_session_credentials(denied, Some(&mgr), Some(api_key_provider.clone()));
|
||||
assert!(
|
||||
resolved.is_empty(),
|
||||
"session credentials must not reach {denied}"
|
||||
);
|
||||
}
|
||||
let resolved = embedding_session_credentials(
|
||||
"https://api.x.ai/v1",
|
||||
Some(&mgr),
|
||||
Some(api_key_provider),
|
||||
);
|
||||
assert!(!resolved.is_empty());
|
||||
}
|
||||
/// Deployment-key path has no recovery (operator owns the bearer).
|
||||
#[tokio::test]
|
||||
async fn refresh_after_unauthorized_is_noop_for_deployment_key() {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@ pub struct AuthManager {
|
|||
disk_state: RwLock<Option<DiskAuthState>>,
|
||||
/// See [`Self::cached_disk_api_key`].
|
||||
static_key_cache: parking_lot::Mutex<Option<StaticKeyCacheEntry>>,
|
||||
/// Model `api_key` / resolved `env_key` for voice/tools without a session.
|
||||
/// Not a session token (those live on `inner`). Prefers over disk; env wins.
|
||||
process_static_api_key: parking_lot::RwLock<Option<String>>,
|
||||
sleep_gate: SleepGate,
|
||||
/// Count of in-flight IdP refreshes (the network call only), so a
|
||||
/// sleep-imminent transition can wait for a refresh straddling suspend to
|
||||
|
|
@ -405,6 +408,7 @@ impl AuthManager {
|
|||
refresh_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
disk_state: RwLock::new(disk_state),
|
||||
static_key_cache: parking_lot::Mutex::new(None),
|
||||
process_static_api_key: parking_lot::RwLock::new(None),
|
||||
sleep_gate: SleepGate::default(),
|
||||
refresh_in_flight: std::sync::atomic::AtomicU32::new(0),
|
||||
refresh_drain_lock: parking_lot::Mutex::new(()),
|
||||
|
|
@ -2221,11 +2225,8 @@ pub(crate) fn compute_proactive_sleep(this: &AuthManager) -> StdDuration {
|
|||
}
|
||||
}
|
||||
|
||||
/// Tools + pager voice: session token first, then static API key.
|
||||
///
|
||||
/// Static fallthrough (`XAI_API_KEY` / `auth.json` `xai::api_key`) makes voice
|
||||
/// work on API-key-only setups without OAuth. API-key login already persists
|
||||
/// the env key to disk.
|
||||
/// Tools + pager voice bearer. Static: env → process model key → disk.
|
||||
/// Kill-switch / `preferred_method = oidc` block static keys.
|
||||
pub(crate) struct SharedAuthKeyProvider(pub Arc<AuthManager>);
|
||||
|
||||
impl xai_grok_tools::types::ApiKeyProvider for SharedAuthKeyProvider {
|
||||
|
|
@ -2266,7 +2267,7 @@ fn prefers_static_api_key(am: &AuthManager) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
/// Env → `auth.json` `xai::api_key`. Off under kill-switch or `preferred_method = oidc`.
|
||||
/// Env → process model key → disk. Off under kill-switch / oidc pin.
|
||||
fn resolve_static_api_key(am: &AuthManager) -> Option<String> {
|
||||
if am.grok_com_config.api_key_auth_disabled() {
|
||||
return None;
|
||||
|
|
@ -2278,6 +2279,7 @@ fn resolve_static_api_key(am: &AuthManager) -> Option<String> {
|
|||
return None;
|
||||
}
|
||||
non_empty_key(crate::agent::auth_method::read_xai_api_key_env().ok())
|
||||
.or_else(|| non_empty_key(am.process_static_api_key.read().clone()))
|
||||
.or_else(|| am.cached_disk_api_key())
|
||||
}
|
||||
|
||||
|
|
@ -2328,6 +2330,12 @@ impl AuthManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the process model key (empty clears). Not for session tokens.
|
||||
pub fn set_process_static_api_key(&self, key: Option<String>) {
|
||||
let key = key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
||||
*self.process_static_api_key.write() = key;
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_key(key: Option<String>) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -3586,6 +3586,105 @@ async fn shared_api_key_provider_disk_memo_follows_rewrites() {
|
|||
assert_eq!(provider.current_api_key_async().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn process_key_from_model_env_key() {
|
||||
use crate::agent::config::{Config, resolve_model_list};
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
const ENV: &str = "TEST_MODEL_ENV_KEY";
|
||||
const TOKEN: &str = "model-env-token";
|
||||
|
||||
let _xai = EnvGuard::unset("XAI_API_KEY");
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let _tok = EnvGuard::set(ENV, TOKEN);
|
||||
|
||||
let dm = crate::models::default_model();
|
||||
let cfg = Config::new_from_toml_cfg(
|
||||
&toml::from_str(&format!(
|
||||
r#"
|
||||
[model."{dm}"]
|
||||
model = "{dm}"
|
||||
env_key = "{ENV}"
|
||||
"#
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let key = resolve_model_list(&cfg, None)
|
||||
.get(dm)
|
||||
.and_then(|m| m.own_credential())
|
||||
.unwrap();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
assert!(mgr.current().is_none());
|
||||
mgr.set_process_static_api_key(Some(key));
|
||||
assert_eq!(
|
||||
shared_api_key_provider(mgr)
|
||||
.current_api_key_async()
|
||||
.await
|
||||
.as_deref(),
|
||||
Some(TOKEN)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn process_key_precedence() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let _xai = EnvGuard::unset("XAI_API_KEY");
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
let provider = shared_api_key_provider(mgr.clone());
|
||||
|
||||
assert_eq!(provider.current_api_key_async().await, None);
|
||||
|
||||
crate::auth::store_api_key(dir.path(), "disk").unwrap();
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("disk")
|
||||
);
|
||||
|
||||
mgr.set_process_static_api_key(Some(" process ".into()));
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("process")
|
||||
);
|
||||
|
||||
{
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "env");
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("env")
|
||||
);
|
||||
}
|
||||
|
||||
mgr.set_process_static_api_key(None);
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("disk")
|
||||
);
|
||||
|
||||
let dir_blocked = tempfile::tempdir().unwrap();
|
||||
let blocked = Arc::new(AuthManager::new(
|
||||
dir_blocked.path(),
|
||||
GrokComConfig {
|
||||
disable_api_key_auth: Some(true),
|
||||
..GrokComConfig::default()
|
||||
},
|
||||
));
|
||||
blocked.set_process_static_api_key(Some("ignored".into()));
|
||||
assert_eq!(
|
||||
shared_api_key_provider(blocked)
|
||||
.current_api_key_async()
|
||||
.await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn expired_oidc() -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: "expired-key".into(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mod model;
|
|||
pub mod oidc;
|
||||
pub(crate) mod recovery;
|
||||
pub(crate) mod refresh;
|
||||
pub(crate) mod single_flight;
|
||||
mod storage;
|
||||
pub(crate) mod token_type;
|
||||
pub(crate) use config::LEGACY_AUTH_SCOPE;
|
||||
|
|
|
|||
359
crates/codegen/xai-grok-shell/src/auth/single_flight.rs
Normal file
359
crates/codegen/xai-grok-shell/src/auth/single_flight.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
//! Single-flight guard for interactive login.
|
||||
//!
|
||||
//! At most one device-code / loopback wait runs at a time: starting a new
|
||||
//! attempt (or an explicit `x.ai/auth/cancel`) cancels the previous one, so
|
||||
//! remint/retry cannot stack device-code mints.
|
||||
//!
|
||||
//! The attempt owns **all** attempt-scoped state — the cancellation token and
|
||||
//! the code/url channels — so replacing an attempt swaps everything
|
||||
//! atomically, and a cancelled predecessor that finishes late structurally
|
||||
//! cannot touch its successor's channels. Generations guard `end()` the same
|
||||
//! way: a stale finisher must not clear a newer attempt. Client `request_seq`
|
||||
//! scopes explicit cancels so a delayed `x.ai/auth/cancel` cannot tear down a
|
||||
//! successor login.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::flow::AuthUrlInfo;
|
||||
|
||||
/// Channels wired between the ACP ext handlers and one interactive auth flow.
|
||||
/// `None` for headless attempts (no URL to show, no code to paste).
|
||||
pub(crate) struct AttemptChannels {
|
||||
/// Forwards pasted codes from `x.ai/auth/submit_code` to the flow.
|
||||
code_tx: tokio::sync::mpsc::Sender<String>,
|
||||
/// Yields the auth URL to `x.ai/auth/get_url`. `Option` so
|
||||
/// [`AuthSingleFlight::take_url_rx`] can move it out while the attempt
|
||||
/// lives on (one-shot read).
|
||||
url_rx: Option<tokio::sync::oneshot::Receiver<AuthUrlInfo>>,
|
||||
}
|
||||
|
||||
struct Attempt {
|
||||
token: CancellationToken,
|
||||
channels: Option<AttemptChannels>,
|
||||
/// Pager `request_seq` for this attempt (scopes delayed cancel RPCs).
|
||||
client_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// Why [`AuthSingleFlight::submit_code`] failed.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SubmitCodeError {
|
||||
/// No interactive attempt is waiting for a code (idle or headless).
|
||||
NoPendingAttempt,
|
||||
/// Channel send failed (attempt channels already closed).
|
||||
SendFailed(tokio::sync::mpsc::error::TrySendError<String>),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AuthSingleFlight {
|
||||
active: RefCell<Option<Attempt>>,
|
||||
generation: Cell<u64>,
|
||||
}
|
||||
|
||||
/// RAII end for a [`AuthSingleFlight::begin`] generation: calls [`AuthSingleFlight::end`]
|
||||
/// on drop so an aborted authenticate future cannot leak attempt state.
|
||||
pub(crate) struct AuthAttemptGuard<'a> {
|
||||
sf: &'a AuthSingleFlight,
|
||||
generation: u64,
|
||||
ended: Cell<bool>,
|
||||
}
|
||||
|
||||
impl AuthAttemptGuard<'_> {
|
||||
/// Explicit end (same as drop). Idempotent.
|
||||
pub(crate) fn end(&self) {
|
||||
if !self.ended.replace(true) {
|
||||
self.sf.end(self.generation);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthAttemptGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.end();
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthSingleFlight {
|
||||
/// Start a new attempt, cancelling any prior in-flight one. Returns the
|
||||
/// new attempt's token and an [`AuthAttemptGuard`] that ends this generation
|
||||
/// on drop (pass no separate `end` — the guard is the only closer).
|
||||
///
|
||||
/// `client_seq` is the pager auth `request_seq` (when known); used by
|
||||
/// [`Self::cancel_for_client_seq`] so a delayed cancel cannot kill a
|
||||
/// successor attempt.
|
||||
pub(crate) fn begin(
|
||||
&self,
|
||||
channels: Option<AttemptChannels>,
|
||||
client_seq: Option<u64>,
|
||||
) -> (CancellationToken, AuthAttemptGuard<'_>) {
|
||||
let generation = self.generation.get().wrapping_add(1);
|
||||
self.generation.set(generation);
|
||||
let token = CancellationToken::new();
|
||||
if let Some(prev) = self.active.borrow_mut().replace(Attempt {
|
||||
token: token.clone(),
|
||||
channels,
|
||||
client_seq,
|
||||
}) {
|
||||
tracing::info!("auth: cancelling prior interactive auth for single-flight");
|
||||
prev.token.cancel();
|
||||
}
|
||||
(
|
||||
token,
|
||||
AuthAttemptGuard {
|
||||
sf: self,
|
||||
generation,
|
||||
ended: Cell::new(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Finish an attempt: drops its token *and channels* only if `generation`
|
||||
/// is still the active one (a stale finisher must not clear a newer
|
||||
/// attempt's state).
|
||||
pub(crate) fn end(&self, generation: u64) {
|
||||
if self.generation.get() == generation {
|
||||
*self.active.borrow_mut() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the active attempt, if any. Idempotent. Prefer
|
||||
/// [`Self::cancel_for_client_seq`] when the caller has a pager `request_seq`
|
||||
/// so a delayed cancel cannot tear down a newer login.
|
||||
pub(crate) fn cancel(&self) {
|
||||
if let Some(prev) = self.active.borrow_mut().take() {
|
||||
tracing::info!("auth: interactive auth cancelled");
|
||||
prev.token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel only if the active attempt was started for `client_seq`. A stale
|
||||
/// cancel (successor already began) is a no-op.
|
||||
pub(crate) fn cancel_for_client_seq(&self, client_seq: u64) {
|
||||
let mut active = self.active.borrow_mut();
|
||||
match active.as_ref() {
|
||||
Some(a) if a.client_seq == Some(client_seq) => {
|
||||
if let Some(prev) = active.take() {
|
||||
tracing::info!(
|
||||
client_seq,
|
||||
"auth: interactive auth cancelled for client request_seq"
|
||||
);
|
||||
prev.token.cancel();
|
||||
}
|
||||
}
|
||||
Some(a) => {
|
||||
tracing::debug!(
|
||||
client_seq,
|
||||
active_client_seq = ?a.client_seq,
|
||||
"auth: ignoring stale cancel for superseded request_seq"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
client_seq,
|
||||
"auth: cancel_for_client_seq with no active attempt"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward a pasted code to the active attempt's flow.
|
||||
pub(crate) fn submit_code(&self, code: String) -> Result<(), SubmitCodeError> {
|
||||
match self
|
||||
.active
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|a| a.channels.as_ref())
|
||||
{
|
||||
Some(ch) => ch
|
||||
.code_tx
|
||||
.try_send(code)
|
||||
.map_err(SubmitCodeError::SendFailed),
|
||||
None => Err(SubmitCodeError::NoPendingAttempt),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the active attempt's URL receiver (one-shot; subsequent calls
|
||||
/// return `None`, as does an idle or headless attempt).
|
||||
pub(crate) fn take_url_rx(&self) -> Option<tokio::sync::oneshot::Receiver<AuthUrlInfo>> {
|
||||
self.active
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
.and_then(|a| a.channels.as_mut().and_then(|ch| ch.url_rx.take()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AttemptChannels {
|
||||
pub(crate) fn new(
|
||||
code_tx: tokio::sync::mpsc::Sender<String>,
|
||||
url_rx: tokio::sync::oneshot::Receiver<AuthUrlInfo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code_tx,
|
||||
url_rx: Some(url_rx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn channels() -> (AttemptChannels, tokio::sync::mpsc::Receiver<String>) {
|
||||
let (code_tx, code_rx) = tokio::sync::mpsc::channel(1);
|
||||
let (_url_tx, url_rx) = tokio::sync::oneshot::channel();
|
||||
(AttemptChannels::new(code_tx, url_rx), code_rx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_cancels_prior_attempt() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (first, _g1) = sf.begin(None, None);
|
||||
let (second, _g2) = sf.begin(None, None);
|
||||
assert!(first.is_cancelled(), "prior attempt must be cancelled");
|
||||
assert!(!second.is_cancelled(), "new attempt must be live");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_stops_active_attempt_and_is_idempotent() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (token, _g) = sf.begin(None, None);
|
||||
sf.cancel();
|
||||
assert!(token.is_cancelled());
|
||||
sf.cancel(); // no active attempt — must not panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_end_does_not_clear_newer_attempt() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (_first, first_guard) = sf.begin(None, None);
|
||||
let first_gen = first_guard.generation();
|
||||
// Keep first_guard alive but end via generation (stale after second begin).
|
||||
let (second, _second_guard) = sf.begin(None, None);
|
||||
sf.end(first_gen); // stale finisher
|
||||
sf.cancel(); // must still cancel the second attempt's token
|
||||
assert!(
|
||||
second.is_cancelled(),
|
||||
"stale end() must not have cleared the active token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_end_drops_the_stored_attempt() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (token, guard) = sf.begin(None, None);
|
||||
guard.end();
|
||||
sf.cancel(); // nothing active — must not cancel the finished attempt
|
||||
assert!(!token.is_cancelled());
|
||||
}
|
||||
|
||||
/// The race the attempt object exists to prevent: a cancelled
|
||||
/// predecessor finishing late must not drop the successor's channels.
|
||||
#[test]
|
||||
fn stale_end_leaves_successor_channels_intact() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (_first, first_guard) = sf.begin(None, None);
|
||||
let first_gen = first_guard.generation();
|
||||
let (ch, mut code_rx) = channels();
|
||||
let (_second, _g2) = sf.begin(Some(ch), Some(2));
|
||||
|
||||
sf.end(first_gen); // stale finisher (attempt #1's cleanup)
|
||||
|
||||
sf.submit_code("1234".into())
|
||||
.expect("successor's code channel must still be wired");
|
||||
assert_eq!(code_rx.try_recv().as_deref(), Ok("1234"));
|
||||
assert!(
|
||||
sf.take_url_rx().is_some(),
|
||||
"successor's url receiver must still be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_code_and_url_rx_absent_when_idle_or_headless() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
assert!(
|
||||
matches!(
|
||||
sf.submit_code("x".into()),
|
||||
Err(SubmitCodeError::NoPendingAttempt)
|
||||
),
|
||||
"idle: no attempt is waiting for a code"
|
||||
);
|
||||
assert!(sf.take_url_rx().is_none());
|
||||
let _g = sf.begin(None, None); // headless attempt: token only
|
||||
assert!(
|
||||
matches!(
|
||||
sf.submit_code("x".into()),
|
||||
Err(SubmitCodeError::NoPendingAttempt)
|
||||
),
|
||||
"headless: no channels"
|
||||
);
|
||||
assert!(sf.take_url_rx().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_for_client_seq_ignores_stale_seq() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (first, _g1) = sf.begin(None, Some(1));
|
||||
let (second, _g2) = sf.begin(None, Some(2));
|
||||
assert!(first.is_cancelled());
|
||||
sf.cancel_for_client_seq(1); // delayed cancel for attempt 1
|
||||
assert!(
|
||||
!second.is_cancelled(),
|
||||
"stale cancel must not tear down the successor"
|
||||
);
|
||||
sf.cancel_for_client_seq(2);
|
||||
assert!(second.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attempt_guard_ends_on_drop() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (token, guard) = sf.begin(None, Some(7));
|
||||
drop(guard);
|
||||
sf.cancel(); // nothing active
|
||||
assert!(!token.is_cancelled());
|
||||
assert!(matches!(
|
||||
sf.submit_code("x".into()),
|
||||
Err(SubmitCodeError::NoPendingAttempt)
|
||||
));
|
||||
}
|
||||
|
||||
/// Headless (and interactive) authenticate `select!`s on this token —
|
||||
/// cancel must interrupt a long wait rather than leaving it racing
|
||||
/// (logout / unscoped cancel path).
|
||||
#[tokio::test]
|
||||
async fn cancel_interrupts_waiting_select() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (cancel, _guard) = sf.begin(None, Some(42)); // headless: no channels
|
||||
let waiter = tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => "cancelled",
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => "timeout",
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
sf.cancel(); // same as handle_logout / unscoped cancel
|
||||
assert_eq!(waiter.await.expect("join"), "cancelled");
|
||||
}
|
||||
|
||||
/// Logout-style unscoped cancel, then a new begin must not see prior channels.
|
||||
#[test]
|
||||
fn cancel_then_begin_is_clean_for_successor() {
|
||||
let sf = AuthSingleFlight::default();
|
||||
let (ch, mut code_rx) = channels();
|
||||
let (old, _g) = sf.begin(Some(ch), Some(1));
|
||||
sf.cancel();
|
||||
assert!(old.is_cancelled());
|
||||
let (ch2, mut code_rx2) = channels();
|
||||
let (new, _g2) = sf.begin(Some(ch2), Some(2));
|
||||
assert!(!new.is_cancelled());
|
||||
sf.submit_code("ok".into()).expect("successor wired");
|
||||
assert_eq!(code_rx2.try_recv().as_deref(), Ok("ok"));
|
||||
assert!(code_rx.try_recv().is_err(), "prior channel must be dead");
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,16 @@ pub fn read_auth_json(auth_file: &Path) -> std::io::Result<AuthStore> {
|
|||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
|
||||
// Tighten world-readable copies (hand-restored, umask edge cases, etc.).
|
||||
// Best-effort: a chmod failure must not block login/read paths.
|
||||
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
|
||||
tracing::warn!(
|
||||
path = %auth_file.display(),
|
||||
error = %e,
|
||||
"auth: failed to enforce owner-only permissions on auth.json"
|
||||
);
|
||||
}
|
||||
|
||||
// Empty files are valid (recover from prior crash/partial write).
|
||||
let trimmed = contents.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
|
@ -114,6 +124,8 @@ pub(crate) fn backup_corrupt_auth_file(path: &Path) -> Option<PathBuf> {
|
|||
|
||||
match std::fs::rename(path, &backup) {
|
||||
Ok(()) => {
|
||||
// Corrupt backups still hold token material — keep them owner-only.
|
||||
let _ = crate::util::secure_file::ensure_owner_only_permissions(&backup);
|
||||
tracing::warn!(
|
||||
original = %path.display(),
|
||||
backup = %backup.display(),
|
||||
|
|
@ -246,9 +258,17 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
|
|||
.into_inner()
|
||||
.map_err(|e| e.into_error())?
|
||||
.sync_all()?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::util::secure_file::set_windows_secure_permissions(path)?;
|
||||
// `open_secure_file` mode bits apply only on create; tighten existing paths.
|
||||
// Best-effort after durable content: a chmod-only failure must not look
|
||||
// like a failed write. The in-place fallback restores the prior snapshot
|
||||
// on any `write_store_to` Err, which would discard freshly written tokens.
|
||||
// Load path re-tightens on next read.
|
||||
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(path) {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
path = %path.display(),
|
||||
"auth: failed to ensure owner-only permissions after write"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -263,6 +283,15 @@ fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::
|
|||
let _ = std::fs::remove_file(auth_file);
|
||||
}
|
||||
std::fs::rename(&tmp, auth_file)?;
|
||||
// Re-assert on the final path (covers rename edge cases / FS quirks).
|
||||
// Best-effort: rename already published the new tokens.
|
||||
if let Err(e) = crate::util::secure_file::ensure_owner_only_permissions(auth_file) {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
path = %auth_file.display(),
|
||||
"auth: failed to ensure owner-only permissions after rename"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -317,10 +346,7 @@ fn restore_prior_bytes(auth_file: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|||
let mut file = open_secure_file(auth_file)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::util::secure_file::set_windows_secure_permissions(auth_file)?;
|
||||
}
|
||||
crate::util::secure_file::ensure_owner_only_permissions(auth_file)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -433,6 +459,46 @@ mod write_fallback_tests {
|
|||
assert_eq!(mode & 0o777, 0o600, "in-place write must stay 0o600");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_tightens_preexisting_world_readable_auth_json() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
let mut loose = std::fs::metadata(&path).unwrap().permissions();
|
||||
loose.set_mode(0o644);
|
||||
std::fs::set_permissions(&path, loose).unwrap();
|
||||
|
||||
write_auth_json(&path, &sample_store()).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"rewrite must tighten preexisting open perms"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_tightens_world_readable_auth_json() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
write_auth_json(&path, &sample_store()).unwrap();
|
||||
let mut loose = std::fs::metadata(&path).unwrap().permissions();
|
||||
loose.set_mode(0o644);
|
||||
std::fs::set_permissions(&path, loose).unwrap();
|
||||
|
||||
let _ = read_auth_json(&path).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"load must tighten open auth.json perms"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `StorageFull` (ENOSPC) failure on the atomic path must fall back to
|
||||
/// the in-place write so the credential still lands on disk.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue