Synced from monorepo
Changes: - grok-shell: request workspaces:read/write OAuth2 scopes - security: fix SSRF bypass via HTTP redirect in hook runner - fix(grok-build): enterprise STT WSS URL + API-key voice bearer - Harden identity-change purge and sync-marker invariants - sandbox + workspace-server: delete the legacy ready-file arm - Show billing URL when browser cannot open - fix(pager): show folder-trust UI in minimal mode - fix(pager): drain task_backgrounded before no-wait headless exit - grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt - Split settings_modal into directory module - Delegate VS Code SSH file links - grok-shell: release the workspace session binding when a session is removed - keep skills reachable when their name collides with a client builtin - Preserve semantic link targets
This commit is contained in:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -22,6 +22,8 @@ fn default_oauth2_scopes() -> Vec<String> {
|
|||
"api:access".into(),
|
||||
"conversations:read".into(),
|
||||
"conversations:write".into(),
|
||||
"workspaces:read".into(),
|
||||
"workspaces:write".into(),
|
||||
]
|
||||
}
|
||||
fn default_team_oauth2_scopes() -> Vec<String> {
|
||||
|
|
@ -33,6 +35,8 @@ fn default_team_oauth2_scopes() -> Vec<String> {
|
|||
"team:read".into(),
|
||||
"conversations:read".into(),
|
||||
"conversations:write".into(),
|
||||
"workspaces:read".into(),
|
||||
"workspaces:write".into(),
|
||||
]
|
||||
}
|
||||
/// Pin automatic auth to one method (`[auth] preferred_method` in config.toml).
|
||||
|
|
@ -381,7 +385,7 @@ mod tests {
|
|||
assert_eq!(PROD_ACCOUNTS_APP_ORIGINS, &["https://accounts.x.ai"]);
|
||||
assert_eq!(allowed_accounts_app_origins(), PROD_ACCOUNTS_APP_ORIGINS);
|
||||
}
|
||||
/// FROZEN client contract: the 8 scopes the xAI OAuth2 client requests.
|
||||
/// FROZEN client contract: the 10 scopes the xAI OAuth2 client requests.
|
||||
/// The server must keep accepting all of them; existing tokens carry
|
||||
/// exactly this set. Frozen OAuth client scope contract.
|
||||
#[test]
|
||||
|
|
@ -399,6 +403,8 @@ mod tests {
|
|||
"api:access",
|
||||
"conversations:read",
|
||||
"conversations:write",
|
||||
"workspaces:read",
|
||||
"workspaces:write",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ pub struct AuthManager {
|
|||
/// disk every few seconds, so per-read logging would flood and no
|
||||
/// logging leaves auth.json loss invisible in production captures.
|
||||
disk_state: RwLock<Option<DiskAuthState>>,
|
||||
/// See [`Self::cached_disk_api_key`].
|
||||
static_key_cache: parking_lot::Mutex<Option<StaticKeyCacheEntry>>,
|
||||
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
|
||||
|
|
@ -402,6 +404,7 @@ impl AuthManager {
|
|||
proactive_starts: std::sync::atomic::AtomicU32::new(0),
|
||||
refresh_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
disk_state: RwLock::new(disk_state),
|
||||
static_key_cache: parking_lot::Mutex::new(None),
|
||||
sleep_gate: SleepGate::default(),
|
||||
refresh_in_flight: std::sync::atomic::AtomicU32::new(0),
|
||||
refresh_drain_lock: parking_lot::Mutex::new(()),
|
||||
|
|
@ -711,6 +714,19 @@ impl AuthManager {
|
|||
self.current().or_else(|| self.expired_auth())
|
||||
}
|
||||
|
||||
/// Cached token if still wire-valid ([`Self::is_token_hard_expired`]),
|
||||
/// ignoring the early-invalidation buffer. For sync callers that cannot
|
||||
/// refresh and must not demote a still-accepted token.
|
||||
pub(crate) fn current_wire_valid(&self) -> Option<GrokAuth> {
|
||||
let auth = self
|
||||
.inner
|
||||
.read()
|
||||
.as_ref()
|
||||
.filter(|a| !self.is_token_hard_expired(a))
|
||||
.cloned()?;
|
||||
self.vet_cached(auth)
|
||||
}
|
||||
|
||||
/// `true` when data collection must be suppressed — the team has ZDR or
|
||||
/// the user opted out of coding data retention. Reads
|
||||
/// [`Self::current_or_expired`] because neither flag changes on token
|
||||
|
|
@ -2205,34 +2221,120 @@ pub(crate) fn compute_proactive_sleep(this: &AuthManager) -> StdDuration {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bridges `Arc<AuthManager>` into the `ApiKeyProvider` trait used by
|
||||
/// tool clients (image_gen, video_gen, web_search, embedding). Sync callers
|
||||
/// get the buffered snapshot; async callers drive the refresh chain.
|
||||
/// 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.
|
||||
pub(crate) struct SharedAuthKeyProvider(pub Arc<AuthManager>);
|
||||
|
||||
impl xai_grok_tools::types::ApiKeyProvider for SharedAuthKeyProvider {
|
||||
fn current_api_key(&self) -> Option<String> {
|
||||
self.0.current_or_expired().map(|a| a.key)
|
||||
if prefers_static_api_key(&self.0) {
|
||||
return resolve_static_api_key(&self.0);
|
||||
}
|
||||
// Wire-valid session > static key > expired session (last resort).
|
||||
// Hard expiry, not the refresh buffer: sync cannot refresh, so a
|
||||
// buffered-but-valid token must still beat static.
|
||||
self.0
|
||||
.current_wire_valid()
|
||||
.map(|a| a.key)
|
||||
.or_else(|| resolve_static_api_key(&self.0))
|
||||
.or_else(|| self.0.current_or_expired().map(|a| a.key))
|
||||
}
|
||||
|
||||
fn current_api_key_async(
|
||||
&self,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + '_>> {
|
||||
let am = self.0.clone();
|
||||
Box::pin(async move { am.get_valid_token().await.ok() })
|
||||
Box::pin(async move {
|
||||
if prefers_static_api_key(&am) {
|
||||
return resolve_static_api_key(&am);
|
||||
}
|
||||
am.get_valid_token()
|
||||
.await
|
||||
.ok()
|
||||
.or_else(|| resolve_static_api_key(&am))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a refreshing [`ApiKeyProvider`](xai_grok_tools::types::ApiKeyProvider)
|
||||
/// from an `Arc<AuthManager>`.
|
||||
///
|
||||
/// This is the public, supported way for out-of-crate consumers (e.g. the
|
||||
/// pager's voice channel) to obtain a bearer that follows the same refresh
|
||||
/// chain as chat / tool traffic, rather than snapshotting a token at startup
|
||||
/// (the static-snapshot bug class). The returned
|
||||
/// provider resolves a fresh bearer per call via
|
||||
/// [`current_api_key_async`](xai_grok_tools::types::ApiKeyProvider::current_api_key_async),
|
||||
/// so it works for both OAuth/session (refreshes) and API-key auth.
|
||||
fn prefers_static_api_key(am: &AuthManager) -> bool {
|
||||
matches!(
|
||||
am.grok_com_config.preferred_method,
|
||||
Some(super::config::PreferredAuthMethod::ApiKey)
|
||||
)
|
||||
}
|
||||
|
||||
/// Env → `auth.json` `xai::api_key`. Off under kill-switch or `preferred_method = oidc`.
|
||||
fn resolve_static_api_key(am: &AuthManager) -> Option<String> {
|
||||
if am.grok_com_config.api_key_auth_disabled() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
am.grok_com_config.preferred_method,
|
||||
Some(super::config::PreferredAuthMethod::Oidc)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
non_empty_key(crate::agent::auth_method::read_xai_api_key_env().ok())
|
||||
.or_else(|| am.cached_disk_api_key())
|
||||
}
|
||||
|
||||
fn api_key_from_auth_file(path: &Path) -> Option<String> {
|
||||
let map = read_auth_json(path).ok()?;
|
||||
non_empty_key(map.get(super::model::API_KEY_SCOPE).map(|a| a.key.clone()))
|
||||
}
|
||||
|
||||
/// Memo for [`AuthManager::cached_disk_api_key`]. `stamp == None` = file absent.
|
||||
struct StaticKeyCacheEntry {
|
||||
stamp: Option<AuthFileStamp>,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
/// (inode, mtime, len). `write_auth_json`'s temp+rename allocates a new inode
|
||||
/// per rewrite, so even a same-length same-mtime rewrite misses the memo.
|
||||
/// Windows has no stable inode (0 there); its fine mtimes suffice.
|
||||
type AuthFileStamp = (u64, Option<std::time::SystemTime>, u64);
|
||||
|
||||
fn auth_file_stamp(path: &Path) -> Option<AuthFileStamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
#[cfg(unix)]
|
||||
let ino = std::os::unix::fs::MetadataExt::ino(&meta);
|
||||
#[cfg(not(unix))]
|
||||
let ino = 0;
|
||||
Some((ino, meta.modified().ok(), meta.len()))
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
/// `xai::api_key` from this manager's auth file, memoized on
|
||||
/// [`AuthFileStamp`]: bearer resolution runs per tool call, so this
|
||||
/// costs a `stat` instead of a read+parse on the hot path.
|
||||
fn cached_disk_api_key(&self) -> Option<String> {
|
||||
let stamp = auth_file_stamp(&self.path);
|
||||
let mut cache = self.static_key_cache.lock();
|
||||
match cache.as_ref() {
|
||||
Some(entry) if entry.stamp == stamp => entry.key.clone(),
|
||||
_ => {
|
||||
let key = stamp
|
||||
.is_some()
|
||||
.then(|| api_key_from_auth_file(&self.path))
|
||||
.flatten();
|
||||
*cache = Some(StaticKeyCacheEntry {
|
||||
stamp,
|
||||
key: key.clone(),
|
||||
});
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_key(key: Option<String>) -> Option<String> {
|
||||
key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
/// Per-request bearer for out-of-crate consumers (e.g. pager voice).
|
||||
pub fn shared_api_key_provider(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> xai_grok_tools::types::SharedApiKeyProvider {
|
||||
|
|
|
|||
|
|
@ -2520,9 +2520,13 @@ fn apply_user_info_enrichment_preserves_token_fields() {
|
|||
|
||||
/// Regression: async provider calls must drive `auth()` so tool requests get refreshed tokens.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial] // reaches `resolve_static_api_key`, which reads the key env vars
|
||||
async fn current_api_key_async_drives_refresh_chain() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
use xai_grok_tools::types::ApiKeyProvider;
|
||||
|
||||
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()));
|
||||
mgr.hot_swap(GrokAuth {
|
||||
|
|
@ -3390,6 +3394,198 @@ async fn shared_api_key_provider_resolves_live_bearer() {
|
|||
);
|
||||
}
|
||||
|
||||
/// No OAuth session → env or auth.json `xai::api_key` for voice/tools.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_static_fallthrough() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
let provider = shared_api_key_provider(mgr.clone());
|
||||
|
||||
{
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "env-only-key");
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("env-only-key")
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let _xai = EnvGuard::unset("XAI_API_KEY");
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
crate::auth::store_api_key(dir.path(), "disk-api-key").unwrap();
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("disk-api-key")
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "env-should-lose");
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "session-bearer".into(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
create_time: Utc::now(),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("session-bearer")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_kill_switch_blocks_static() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "blocked");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig {
|
||||
disable_api_key_auth: Some(true),
|
||||
..GrokComConfig::default()
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
shared_api_key_provider(mgr).current_api_key_async().await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_oidc_preferred_blocks_static() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "should-not-use");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig {
|
||||
preferred_method: Some(crate::auth::PreferredAuthMethod::Oidc),
|
||||
..GrokComConfig::default()
|
||||
},
|
||||
));
|
||||
assert_eq!(
|
||||
shared_api_key_provider(mgr).current_api_key_async().await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// preferred_method=api_key: leftover session must not beat static API key.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_api_key_preferred_skips_session() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "static-preferred");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig {
|
||||
preferred_method: Some(crate::auth::PreferredAuthMethod::ApiKey),
|
||||
..GrokComConfig::default()
|
||||
},
|
||||
));
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "leftover-oidc".into(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
create_time: Utc::now(),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
assert_eq!(
|
||||
shared_api_key_provider(mgr)
|
||||
.current_api_key_async()
|
||||
.await
|
||||
.as_deref(),
|
||||
Some("static-preferred")
|
||||
);
|
||||
}
|
||||
|
||||
/// Expired OAuth must not block static fallthrough on the sync path.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_sync_falls_through_when_session_expired() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "static-after-expiry");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "expired-oidc".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(Utc::now() - Duration::hours(1)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
let provider = shared_api_key_provider(mgr);
|
||||
assert_eq!(
|
||||
provider.current_api_key().as_deref(),
|
||||
Some("static-after-expiry"),
|
||||
"sync path must not return a dead session token over a live static key"
|
||||
);
|
||||
assert_eq!(
|
||||
provider.current_api_key_async().await.as_deref(),
|
||||
Some("static-after-expiry")
|
||||
);
|
||||
}
|
||||
|
||||
/// A session inside the early-invalidation buffer is still wire-valid and
|
||||
/// must beat a static key on the sync path.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_sync_buffered_session_beats_static() {
|
||||
use xai_grok_test_support::EnvGuard;
|
||||
use xai_grok_tools::types::ApiKeyProvider;
|
||||
|
||||
let _legacy = EnvGuard::unset("GROK_CODE_XAI_API_KEY");
|
||||
let _key = EnvGuard::set("XAI_API_KEY", "leftover-static");
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
||||
// Two minutes out: inside the 5-minute buffer, but accepted on the wire.
|
||||
mgr.hot_swap(GrokAuth {
|
||||
key: "buffered-oidc".into(),
|
||||
auth_mode: AuthMode::Oidc,
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at: Some(Utc::now() + Duration::minutes(2)),
|
||||
..GrokAuth::test_default()
|
||||
});
|
||||
let provider = super::SharedAuthKeyProvider(mgr);
|
||||
assert_eq!(provider.current_api_key().as_deref(), Some("buffered-oidc"));
|
||||
}
|
||||
|
||||
/// Auth.json create, rewrite (including same-length, caught by the inode in
|
||||
/// the memo stamp), and logout must all invalidate the disk static-key memo.
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn shared_api_key_provider_disk_memo_follows_rewrites() {
|
||||
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);
|
||||
|
||||
assert_eq!(provider.current_api_key_async().await, None);
|
||||
|
||||
for key in ["first-key", "fresh-key", "second-key-rotated"] {
|
||||
crate::auth::store_api_key(dir.path(), key).unwrap();
|
||||
assert_eq!(provider.current_api_key_async().await.as_deref(), Some(key));
|
||||
}
|
||||
|
||||
crate::auth::clear_api_key(dir.path()).unwrap();
|
||||
assert_eq!(provider.current_api_key_async().await, None);
|
||||
}
|
||||
|
||||
fn expired_oidc() -> GrokAuth {
|
||||
GrokAuth {
|
||||
key: "expired-key".into(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue