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:
grokkybara[bot] 2026-07-16 20:27:30 +01:00
commit 8adf9013a0
117 changed files with 16998 additions and 14540 deletions

View file

@ -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",
]
);
}

View file

@ -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 {

View file

@ -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(),