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

@ -21,16 +21,7 @@ pub const GROK_CHAT_MODE_ENV: &str = "GROK_CHAT_MODE";
/// True when the process is a gateway light-frontend (`--chat`) agent.
/// Hard-off in release builds so it can't be enabled via env.
pub fn process_chat_mode_enabled() -> bool {
if true {
return false;
}
match std::env::var(GROK_CHAT_MODE_ENV) {
Ok(v) => {
let v = v.trim();
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
}
Err(_) => false,
}
false
}
#[derive(Clone)]
struct CachedModes {

View file

@ -115,10 +115,10 @@ impl EnvKeys {
mut getenv: impl FnMut(&str) -> Option<String>,
) -> Option<String> {
for name in self.names() {
if let Some(value) = getenv(name) {
if !value.trim().is_empty() {
return Some(value);
}
if let Some(value) = getenv(name)
&& !value.trim().is_empty()
{
return Some(value);
}
}
None
@ -285,7 +285,8 @@ impl EndpointsConfig {
}
/// Layer the `[endpoints]` table from `config` over the env/default base.
/// No field is derived from another — defaulting is done by the resolvers.
pub(crate) fn from_config_value(config: &toml::Value) -> Self {
/// `pub`: the pager resolves the voice STT base through this same path.
pub fn from_config_value(config: &toml::Value) -> Self {
let default = Self::default();
let external_otel_master_switch = default.external_otel_master_switch;
let mut base = match toml::Value::try_from(default) {

View file

@ -736,8 +736,10 @@ pub struct MvpAgent {
/// Context for managing background copy operations (e.g., copying ignored files)
pub(crate) background_copy_context: BackgroundCopyContext,
/// LEADER-SAFE(per-session): keyed by SessionId, no cross-session iteration.
/// Released by `remove_session`.
pub(crate) session_turn_numbers: RefCell<HashMap<acp::SessionId, u64>>,
/// LEADER-SAFE(per-session): keyed by SessionId, no cross-session iteration.
/// Released by `remove_session`.
permission_event_receivers: RefCell<
HashMap<acp::SessionId, tokio::sync::mpsc::UnboundedReceiver<PermissionEvent>>,
>,
@ -772,7 +774,7 @@ pub struct MvpAgent {
/// transiently degraded when a reconnect replays `session/load` (e.g.
/// fetch still in flight after a leader restart), so the prompt path
/// re-checks and self-heals — or (b) the user explicitly switches
/// models via `set_session_model`.
/// models via `set_session_model`. Released by `remove_session`.
model_unavailable_sessions: RefCell<std::collections::HashMap<String, acp::ModelId>>,
/// Unified sender for all subagent coordinator events.
/// LEADER-SAFE(shared): channel is multi-producer, coordinator drains.
@ -2286,11 +2288,8 @@ async fn handle_synthetic_turn_trace(
model,
)
};
let trace_context = {
let this = agent_ref.get();
let ctx = this.get_trace_context(&info, turn_number).await;
ctx
};
let this = agent_ref.get();
let trace_context = this.get_trace_context(&info, turn_number).await;
let Some(ctx) = trace_context else {
tracing::info!(
session_id = % request.session_id.0, prompt_id = % request.prompt_id,

View file

@ -30,20 +30,26 @@ impl MvpAgent {
});
}
}
/// Remove a session and its thread handle **without** finalizing the cloud
/// replica.
///
/// Used for dead-actor reaping and idle-unload: the conversation stays
/// resumable on disk, so it must NOT be marked "done" upstream. Genuine
/// terminal closes go through [`MvpAgent::close_session_explicit`]. Also
/// drops the `session_live_state` entry so that map stays bounded.
/// Remove a session and its thread handle without finalizing the cloud
/// replica; the conversation stays resumable on disk. Reached by
/// dead-actor reaping and the terminal close and delete paths. Idle
/// unload does not route here: `handle_evict_sessions` removes its
/// handle inline and keeps the thread for reconnect.
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
self.sessions.borrow_mut().remove(id);
self.prompt_intake_locks.borrow_mut().remove(id);
self.session_threads.borrow_mut().remove(id);
self.session_index_claims.borrow_mut().remove(id);
self.require_gateway_sessions.borrow_mut().remove(id);
self.model_unavailable_sessions
.borrow_mut()
.remove(id.0.as_ref());
self.permission_event_receivers.borrow_mut().remove(id);
self.session_turn_numbers.borrow_mut().remove(id);
self.session_live_state.borrow_mut().remove(id);
if let Some(ops) = self.workspace_ops.borrow().as_ref() {
ops.end_local_session(id.0.as_ref());
}
}
/// Get-or-create the per-session prompt-intake lock (see
/// [`Self::prompt_intake_locks`]). Cheap clone of the shared `Rc`.

View file

@ -2906,6 +2906,55 @@ fn chat_session_spawn_options_matches_thin_profile() {
"K10 thin profile must use PersistenceHandle::noop()"
);
}
/// `remove_session` releases the workspace binding and drains the
/// per-session side maps. Test agents default to `workspace_ops = None`,
/// so no other test reaches the release.
#[tokio::test]
async fn remove_session_releases_workspace_binding_and_side_maps() {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("test-session-workspace-release");
let ops = xai_grok_workspace::WorkspaceOps::for_test();
let toolset =
std::sync::Arc::new(xai_grok_tools::registry::types::FinalizedToolset::empty_for_test());
let toolset_weak = std::sync::Arc::downgrade(&toolset);
ops.bind_local_session(
sid.0.as_ref(),
std::env::temp_dir(),
xai_hunk_tracker::HunkTrackerHandle::noop(),
toolset,
None,
)
.expect("bind_local_session must succeed");
assert!(toolset_weak.upgrade().is_some());
*agent.workspace_ops.borrow_mut() = Some(ops);
agent.model_unavailable_sessions.borrow_mut().insert(
sid.0.to_string(),
acp::ModelId::new(std::sync::Arc::from("gone-model")),
);
agent
.session_turn_numbers
.borrow_mut()
.insert(sid.clone(), 3);
let (_permission_tx, permission_rx) =
tokio::sync::mpsc::unbounded_channel::<xai_grok_workspace::permission::PermissionEvent>();
agent
.permission_event_receivers
.borrow_mut()
.insert(sid.clone(), permission_rx);
agent.remove_session(&sid);
assert!(
toolset_weak.upgrade().is_none(),
"the workspace binding must release the toolset"
);
assert!(
!agent
.model_unavailable_sessions
.borrow()
.contains_key(sid.0.as_ref())
);
assert!(!agent.session_turn_numbers.borrow().contains_key(&sid));
assert!(!agent.permission_event_receivers.borrow().contains_key(&sid));
}
/// Without a bridge, `ext_method` falls through to the unchanged local
/// dispatch (`rewind::handle`), which reports the missing session — proving
/// the routing hook is skipped in local mode.

View file

@ -544,17 +544,15 @@ pub(crate) async fn handle_subagent_request(
forked_conversation,
inherited_prefix_len.unwrap_or(0),
);
if crate::session::is_cursor_user_template(&definition.user_message_template)
&& context_source != InitialContextSource::Resumed && !verbatim_mirror_fork
{} else if context_source != InitialContextSource::Resumed && !verbatim_mirror_fork {
if let Some(ref pi) = effective_runtime.persona_instructions {
let reminder = xai_grok_sampling_types::conversation::ConversationItem::system_reminder(
format!("<system-reminder>\n{pi}\n</system-reminder>"),
);
let insert_at = inherited_prefix_len.min(forked_conversation.len());
forked_conversation.insert(insert_at, reminder);
inherited_prefix_len += 1;
}
if context_source != InitialContextSource::Resumed && !verbatim_mirror_fork
&& let Some(ref pi) = effective_runtime.persona_instructions
{
let reminder = xai_grok_sampling_types::conversation::ConversationItem::system_reminder(
format!("<system-reminder>\n{pi}\n</system-reminder>"),
);
let insert_at = inherited_prefix_len.min(forked_conversation.len());
forked_conversation.insert(insert_at, reminder);
inherited_prefix_len += 1;
}
let effective_source_str = match &context_source {
InitialContextSource::New => "new",
@ -1208,6 +1206,7 @@ pub(crate) async fn handle_subagent_request(
};
if cancel_token.is_cancelled() {
pending_guard.defuse();
ctx.workspace_ops.end_local_session(child_session_id.0.as_ref());
cancel_pending_subagent_at_promote(
request,
&child_handle,
@ -1889,6 +1888,7 @@ pub(crate) async fn handle_subagent_request(
(None, None) => {}
}
let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown);
ctx.workspace_ops.end_local_session(child_session_id.0.as_ref());
let mut disposed_snapshot_ref: Option<String> = None;
let mut worktree_removed = false;
if let Some(ref wt_path) = worktree_path {

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

View file

@ -817,12 +817,13 @@ impl StorageMode {
pub use xai_grok_config::ConfigLayers;
pub use xai_grok_config::{
MDM_REQUIREMENTS_SOURCE, RequirementsLayer, RequirementsSource, ServingIdentity, SyncMarker,
claude_managed_settings_probe_path, fail_closed_flag_from_str,
is_managed_config_hard_stale_for, is_managed_config_stale_for, load_config_file,
load_from_disk, load_managed_config, load_merged_requirements, load_system_managed_config,
load_toml_file, managed_config_identity_changed, managed_deployment_id,
managed_policy_compromised_for, mark_managed_config_synced, requirements_layers,
system_config_dir, user_grok_home,
claude_managed_settings_probe_path, confirmed_team_switch, confirmed_team_switch_at,
fail_closed_flag_from_str, is_managed_config_hard_stale_for, is_managed_config_stale_for,
load_config_file, load_from_disk, load_managed_config, load_merged_requirements,
load_system_managed_config, load_toml_file, managed_config_identity_changed_at,
managed_deployment_id, managed_policy_compromised_for, mark_managed_config_synced,
mark_managed_config_synced_at, normalize_identity, requirements_layers, system_config_dir,
user_grok_home,
};
/// Map of "dotted.path" to which config file the value came from.
pub fn config_origins(

View file

@ -5,9 +5,8 @@ use crate::agent::MvpAgent;
use agent_client_protocol as acp;
#[tracing::instrument(skip_all, fields(method = %args.method))]
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
if true {
{
let _ = (agent, args);
return Err(acp::Error::method_not_found());
Err(acp::Error::method_not_found())
}
Err(acp::Error::method_not_found())
}

View file

@ -1099,7 +1099,6 @@ async fn handle_workspace_start(
upload_queue_enabled,
project_lsp_trusted,
None,
None,
false,
false,
)

View file

@ -7,29 +7,45 @@ use crate::auth::GrokAuth;
pub use response::ManagedConfigError;
use response::{ApplyOutcome, ManagedConfigResponse, ManagedConfigSource, verify_signed_envelope};
/// Delete the server-synced files (incl. the sync-marker cache); never the
/// user's `config.toml`.
/// Server-synced policy artifacts. Excludes the sync marker ([`remove_managed_config_files`]
/// removes that last, only on full success).
pub const MANAGED_ARTIFACT_FILES: [&str; 3] = [
xai_grok_config::MANAGED_CONFIG_FILENAME,
xai_grok_config::REQUIREMENTS_FILENAME,
xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE,
];
/// Delete server-synced files then the marker (never `config.toml`).
fn remove_managed_config_files(home: &std::path::Path) {
// Marker LAST: every crash prefix keeps the identity-change detector armed, so the next
// start re-runs the purge and converges offline instead of refusing on a foreign sidecar.
for name in [
"managed_config.toml",
"requirements.toml",
xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE,
"managed_config_cache.json",
] {
remove_synced_file(home, name, "removed managed config file");
let mut artifacts_removed = true;
for name in MANAGED_ARTIFACT_FILES {
artifacts_removed &= remove_synced_file(home, name, "removed managed config file");
}
// A hard kill mid-write can leave a `.tmp` marker/sidecar behind; best-effort sweep so they
// don't accumulate (a concurrent writer's in-flight temp may also go — its rename fails and
// self-heals next check).
// Marker last, only on full success: crash/error leaves the detector armed for the next start.
if artifacts_removed {
remove_synced_file(
home,
xai_grok_config::MANAGED_CONFIG_CACHE_FILE,
"removed managed config file",
);
}
// Best-effort sweep of mid-write `.tmp` leftovers (a concurrent writer's temp may go too —
// its rename fails and self-heals).
let atomic_write_tmp_prefixes = [
format!("{}.", xai_grok_config::MANAGED_CONFIG_CACHE_FILE),
format!(
"{}.",
xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE
),
];
if let Ok(entries) = std::fs::read_dir(home) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let is_write_tmp = name.ends_with(".tmp")
&& (name.starts_with("managed_config_cache.json.")
|| name.starts_with("managed_config.sig.json."));
&& atomic_write_tmp_prefixes
.iter()
.any(|prefix| name.starts_with(prefix.as_str()));
if is_write_tmp {
let _ = std::fs::remove_file(entry.path());
}
@ -37,13 +53,18 @@ fn remove_managed_config_files(home: &std::path::Path) {
}
}
fn remove_synced_file(home: &std::path::Path, name: &str, why: &str) {
/// Returns whether the path is gone (removed or already absent); `false` = removal failed.
fn remove_synced_file(home: &std::path::Path, name: &str, why: &str) -> bool {
let path = home.join(name);
match remove_managed_path(&path) {
Ok(true) => tracing::info!("{why}"),
Ok(false) => {}
Ok(true) => {
tracing::info!(file = %path.display(), "{why}");
true
}
Ok(false) => true,
Err(e) => {
tracing::warn!(error = %e, "failed to remove managed config file")
tracing::warn!(file = %path.display(), error = %e, "failed to remove managed config file");
false
}
}
}
@ -180,6 +201,10 @@ impl SyncBudget {
/// on timeout the heal proceeds with no refreshed override.
const SESSION_START_AUTH_DEADLINE: std::time::Duration = std::time::Duration::from_secs(8);
/// One retry of the gate purge's lock ([`purge_prior_tenant_on_identity_change`]): a routine
/// concurrent apply shouldn't become a session-start refusal, but a wedged holder can't stall start.
const PURGE_LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
/// Exponential backoff for retry `attempt` (caller guarantees `attempt >= 1`).
/// Base is 1s; `GROK_DEPLOYMENT_CONFIG_BACKOFF_MS` overrides it for tests.
fn retry_backoff(attempt: u32) -> std::time::Duration {
@ -224,8 +249,14 @@ fn apply_managed_config(
use crate::util::config::atomic_write_string;
let artifacts = [
("managed_config.toml", body.managed_config.as_deref()),
("requirements.toml", body.requirements.as_deref()),
(
xai_grok_config::MANAGED_CONFIG_FILENAME,
body.managed_config.as_deref(),
),
(
xai_grok_config::REQUIREMENTS_FILENAME,
body.requirements.as_deref(),
),
];
let mut changed = false;
@ -432,46 +463,28 @@ pub async fn sync() -> Result<bool, ManagedConfigError> {
struct SyncOutcome {
wrote: bool,
/// The server returned non-empty config for the consulted principal — true
/// even when a concurrent writer held the lock and our write was skipped, so
/// `grok setup` doesn't misreport a lock skip as "no config".
/// Server returned a config row for the consulted principal (independent of apply).
served: bool,
/// Which credential was consulted, so callers word team-vs-deployment
/// messages by what actually served, not just what's configured.
/// Apply persisted nothing and recorded no marker — see [`ApplyOutcome::Skipped`].
skipped: bool,
/// Credential consulted (team vs deployment wording for callers).
source: Option<ManagedConfigSource>,
/// Team id, or the deploy-key path's server `deployment_id` (deploy-key identity is `key_fingerprint`, not this).
principal: Option<String>,
/// Artifacts the server served, recorded so staleness can spot a later deletion.
had_managed_config: bool,
had_requirements: bool,
/// Deploy-key fingerprint that served, else `None` — the deploy-key identity (see [`crate::config::ServingIdentity`]).
key_fingerprint: Option<String>,
/// The served `fail_closed` opt-in, recorded in the marker for the gate.
fail_closed: bool,
/// Verification was active and the envelope was rejected, so nothing was persisted.
/// Suppresses the sync marker (it would describe a body never written).
/// Verification active and envelope rejected — nothing persisted.
signature_rejected: bool,
}
impl SyncOutcome {
/// `principal` / `key_fingerprint` are the two dimensions that differ between the
/// deploy-key and team paths; everything else derives from the body and the outcome.
/// Reports only what callers render; marker identity fields live in [`apply_fetched`].
fn from_fetch(
body: &ManagedConfigResponse,
source: ManagedConfigSource,
principal: Option<String>,
key_fingerprint: Option<String>,
outcome: &ApplyOutcome,
) -> Self {
Self {
wrote: outcome.wrote(),
served: body.config_exists(),
skipped: outcome.skipped(),
source: Some(source),
principal,
had_managed_config: body.has_managed_config(),
had_requirements: body.has_requirements(),
key_fingerprint,
fail_closed: body.requirements_fail_closed(),
signature_rejected: outcome.signature_rejected(),
}
}
@ -490,29 +503,6 @@ async fn sync_bounded(
}
}
/// `team_override` pins a specific team principal (the just-authenticated one,
/// post-login) instead of re-deriving the team from `auth.json`; `None` uses
/// [`read_active_team_auth`] (the current eligible team).
async fn sync_with_budget(
budget: SyncBudget,
team_override: Option<GrokAuth>,
) -> Result<SyncOutcome, ManagedConfigError> {
let outcome = sync_inner(budget, team_override).await?;
// Mark only when a principal was consulted AND the fetch wasn't signature-rejected —
// a rejected fetch persisted nothing, so marking would claim an unwritten body. Lock
// contention still marks (the holder persists the same config).
if outcome.source.is_some() && !outcome.signature_rejected {
crate::config::mark_managed_config_synced(crate::config::SyncMarker {
principal: outcome.principal.as_deref(),
had_managed_config: outcome.had_managed_config,
had_requirements: outcome.had_requirements,
key_fingerprint: outcome.key_fingerprint.as_deref(),
fail_closed: outcome.fail_closed,
});
}
Ok(outcome)
}
/// A server response paired with the credential that fetched it.
enum FetchedConfig {
DeploymentKey {
@ -581,7 +571,10 @@ async fn fetch_for_principal(
Ok(FetchedConfig::NoPrincipal)
}
async fn sync_inner(
/// `team_override` pins a specific team principal (the just-authenticated one,
/// post-login) instead of re-deriving the team from `auth.json`; `None` uses
/// [`read_active_team_auth`]. Marker is written under the lock by [`apply_fetched`].
async fn sync_with_budget(
budget: SyncBudget,
team_override: Option<GrokAuth>,
) -> Result<SyncOutcome, ManagedConfigError> {
@ -595,59 +588,34 @@ async fn sync_inner(
body.deployment_id.as_deref(),
Some(&fingerprint),
)?;
// Record the served deployment as the marker principal so the load-time
// gate rejects a cross-tenant signed policy. The VERIFIED payload's id is
// preferred — the signed-empty response carries it only inside the payload.
let principal = outcome
.signed_deployment_id()
.map(str::to_owned)
.or_else(|| body.deployment_id.clone());
Ok(SyncOutcome::from_fetch(
&body,
source,
principal,
Some(fingerprint),
&outcome,
))
Ok(SyncOutcome::from_fetch(&body, source, &outcome))
}
FetchedConfig::Team { auth, body } => {
let source = ManagedConfigSource::TeamOauth;
let outcome = apply_fetched(&body, source, auth.team_id.as_deref(), None)?;
// Team identity is bound via principal (team id), not a key fingerprint.
Ok(SyncOutcome::from_fetch(
&body,
source,
auth.team_id.clone(),
None,
&outcome,
))
let outcome = apply_fetched(&body, source, auth.team_id.as_deref(), None)?;
Ok(SyncOutcome::from_fetch(&body, source, &outcome))
}
FetchedConfig::NoPrincipal => Ok(SyncOutcome {
wrote: false,
served: false,
skipped: false,
source: None,
principal: None,
had_managed_config: false,
had_requirements: false,
key_fingerprint: None,
fail_closed: false,
signature_rejected: false,
}),
}
}
/// Apply a fetched response under the cross-process lock (skips if another process holds it — its sync supersedes ours).
/// `new_principal` / `new_key_fingerprint` identify who serves now, so a confirmed switch evicts prior artifacts first.
/// Apply under the cross-process lock (`Skipped` if contended — holder's sync supersedes).
/// `new_principal` / `new_key_fingerprint` are the serving identity for pre-write eviction.
fn apply_fetched(
body: &ManagedConfigResponse,
source: ManagedConfigSource,
new_principal: Option<&str>,
new_key_fingerprint: Option<&str>,
) -> std::io::Result<ApplyOutcome> {
// Verify BEFORE persisting anything: on failure persist NOTHING (no evict, no write,
// no marker), so the prior trusted policy survives a bad fetch. Verification is pure,
// so it also runs before the lock — a lock-skip must not report Applied for an
// envelope that would have failed.
// Verify before lock/persist: prior trusted policy survives a bad fetch. Pure so a
// lock-skip never reports Applied for an envelope that would have failed.
let verified = if xai_grok_config::signed_policy::verification_active() {
match verify_signed_envelope(body, active_team_id_any_expiry().as_deref()) {
Ok(verified) => Some(verified),
@ -665,53 +633,55 @@ fn apply_fetched(
let home = crate::util::grok_home::grok_home();
let Some(_lock) = try_lock_managed_config(&home) else {
tracing::debug!("managed config locked by another process; skipping apply");
return Ok(ApplyOutcome::Applied {
wrote: false,
signed_deployment_id,
});
return Ok(ApplyOutcome::Skipped);
};
// Re-check under the lock that the credential still exists. A logout during
// the fetch runs `clear_orphan`; without this, the in-flight write would
// restore that principal's policy right after it was cleared.
// Credential may have vanished mid-fetch (logout → clear_orphan); don't restore it.
if !credential_present(source) {
tracing::info!("credential gone since fetch started; skipping apply");
return Ok(ApplyOutcome::Applied {
wrote: false,
signed_deployment_id,
});
return Ok(ApplyOutcome::Skipped);
}
// On a confirmed switch, evict prior files before writing the new ones — else an artifact the old
// principal served but the new one omits keeps enforcing. Never fires on first sync / signed-out / pre-upgrade.
if crate::config::managed_config_identity_changed(new_principal, new_key_fingerprint) {
// Confirmed switch: evict first so omitted artifacts from the prior principal don't stick.
// Same locked `home` as the flock + marker write (no re-resolve).
if crate::config::managed_config_identity_changed_at(&home, new_principal, new_key_fingerprint)
{
evict_prior_managed_config(&home);
}
let wrote = apply_managed_config(&home, body)?;
// The sidecar is written AFTER the policy files, so a present sidecar always covers
// the final on-disk set; converge over a squatting directory first (it would fail
// the rename forever, leaving the online self-heal unable to recover).
// Sidecar after policy files so a present sidecar covers the final set; clear dir squats
// that would fail the atomic rename forever.
if let Some(verified) = verified {
clear_squatting_dir(&home.join(xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE));
xai_grok_config::signed_policy::write_sidecar(&home, &verified.sidecar)?;
}
Ok(ApplyOutcome::Applied {
wrote,
signed_deployment_id,
})
// Marker last, still under the lock: written post-release, a concurrent purge could
// delete the files it describes. A squatting dir would fail the atomic rename forever.
clear_squatting_dir(&home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE));
crate::config::mark_managed_config_synced_at(
&home,
crate::config::SyncMarker {
// DK: prefer verified payload deployment id (signed-empty only has it there).
// Team: always the serving team — a deployment-signed envelope must not rebind it.
principal: if new_key_fingerprint.is_some() {
signed_deployment_id.as_deref().or(new_principal)
} else {
new_principal
},
had_managed_config: body.has_managed_config(),
had_requirements: body.has_requirements(),
key_fingerprint: new_key_fingerprint,
fail_closed: body.requirements_fail_closed(),
},
);
Ok(ApplyOutcome::Applied { wrote })
}
/// Remove the prior principal's policy artifacts on a confirmed identity switch. Leaves the marker
/// (the next `mark_managed_config_synced` overwrites it) and never touches the user's `config.toml`.
/// Evict the prior principal's policy artifacts on a confirmed switch; this apply then
/// writes the new set and rebinds the marker. Includes the sidecar — a verification-inactive
/// build must not leave the prior tenant's sidecar to read foreign-bound on a signing build.
fn evict_prior_managed_config(home: &std::path::Path) {
remove_synced_file(
home,
"managed_config.toml",
"evicted prior principal's managed config",
);
remove_synced_file(
home,
"requirements.toml",
"evicted prior principal's requirements",
);
for name in MANAGED_ARTIFACT_FILES {
remove_synced_file(home, name, "evicted prior principal's artifact");
}
}
/// Whether the credential a fetch used is still present. Mirrors the
@ -811,7 +781,8 @@ fn serving_identity_from(team_id: Option<String>) -> crate::config::ServingIdent
fingerprint: deployment_key_fingerprint(&key),
};
}
match team_id {
// Blank = unknown; trimmed (same rule as the marker write) so whitespace isn't identity.
match crate::config::normalize_identity(team_id.as_deref()) {
Some(team_id) => ServingIdentity::Team(team_id),
None => ServingIdentity::None,
}
@ -832,10 +803,11 @@ pub fn active_team_id_any_expiry() -> Option<String> {
store
.values()
.find(|a| a.is_team_principal())
.and_then(|a| a.team_id.clone())
// A blank team_id (malformed auth.json) is unknown, not a distinct identity: it must not
// feed the gate's identity checks, the tenant-switch purge, or the envelope binding.
.filter(|id| !id.trim().is_empty())
// Blank → None, trimmed: a malformed/padded auth.json team_id must read as the SAME
// identity everywhere it feeds — the gate, the tenant-switch purge, and the envelope
// binding (an untrimmed id here would fail `check_fetch_identity` against a trimmed
// signed payload forever).
.and_then(|a| crate::config::normalize_identity(a.team_id.as_deref()))
}
/// Like [`current_serving_identity`] but IGNORING token expiry, for the enforcement gate:
@ -895,49 +867,54 @@ const MANAGED_POLICY_MISSING_MSG: &str = "Managed policy is required for this ac
missing or could not be verified, and could not be restored from the server.\nThis check needs \
network access: reconnect and start again. If you can't reconnect, contact your administrator.";
/// Best-effort no-network fail-closed gate on every session-start path: a managed principal whose opted-in
/// policy can't be established gets no unmanaged session. With no signing key it reads the user-writable
/// marker (a local user can disarm it by editing one field); non-forgeable enforcement is the trust-rooted
/// layers (root-owned path, MDM, signed cache). No client env disables it; recovery stays open (reconnect /
/// `grok setup`); ceasing to serve `fail_closed` rolls back.
/// Fail-closed session-start gate for managed principals. On a confirmed offline team
/// switch, first purges the prior team's artifacts ([`purge_prior_tenant_on_identity_change`]).
/// Without a signing key the user-writable marker is best-effort; root/MDM/signed cache
/// are the non-forgeable layers. Recovery: reconnect / `grok setup`; ceasing to serve
/// `fail_closed` rolls back.
pub fn managed_policy_gate() -> Result<(), String> {
// Skip under the lib unit-test build only: `bootstrap` reaches this without a staged
// `GROK_HOME` and would flake on the dev machine's real marker/auth. The pure decision
// is unit-tested; the integration tests (no `cfg(test)`) exercise this real path.
// Lib unit tests skip: bootstrap would hit the host's real marker/auth. Pure decision
// is unit-tested; integration tests exercise this path.
if cfg!(test) {
return Ok(());
}
// Purge first: an offline team switch would otherwise read as a substituted cache and refuse.
// Purge first so an offline team switch isn't misread as a substituted cache.
purge_prior_tenant_on_identity_change();
managed_policy_gate_decision(
managed_principal_present(),
// Expiry-IGNORING identity: a backdated `auth.json` must not resolve the team to
// `None` and relax the signed-cache binding (the gate is the enforcement path).
// Expiry-ignoring: a backdated auth.json must not resolve Team→None and relax binding.
crate::config::managed_policy_compromised_for(&current_serving_identity_any_expiry()),
)
}
/// On a confirmed team switch (same [`crate::config::managed_config_identity_changed`] detector
/// as the apply-path eviction), purge the prior team's artifacts so the gate permits the new
/// team instead of refusing over A's now-foreign marker/sidecar; the next online fetch applies
/// B's policy. Signed-out, first-sync, and same-team sessions are no-ops.
/// Deploy-key machines never purge here: the key is local config any process can set, so an
/// offline "switch" is tamper, not identity — genuine rotations evict online via apply.
/// Detector + delete run under the managed-config lock (no rebind to B between them);
/// contention skips — the holder owns the transition, like [`clear_orphan`].
/// Residual: forging A→B→A offline sheds A's policy until its next online fetch — the same
/// self-healing class as deleting the user-writable files outright; /etc/grok and MDM are unaffected.
/// Purge prior team (A) artifacts on a confirmed offline team switch so the gate admits
/// team B. Detector is marker-scoped ([`crate::config::confirmed_team_switch`]): key-scoped
/// markers never purge here; config.toml blips are not switches. Under the managed-config
/// lock (one retry on contention, else skip like [`clear_orphan`]); a skip may refuse one
/// signed-build start until the next purge.
fn purge_prior_tenant_on_identity_change() {
let crate::config::ServingIdentity::Team(team_id) = current_serving_identity_any_expiry()
else {
return;
};
// Same home for pre-check, lock, detector, and delete.
let home = crate::util::grok_home::grok_home();
let Some(_lock) = try_lock_managed_config(&home) else {
return; // another process is mid-apply/remove; it owns the transition
// Unlocked pre-check: common no-switch start takes no lock; re-check under lock before delete.
if crate::config::confirmed_team_switch_at(&home, &team_id).is_none() {
return;
}
let Some(_lock) = try_lock_managed_config(&home).or_else(|| {
std::thread::sleep(PURGE_LOCK_RETRY_DELAY);
try_lock_managed_config(&home)
}) else {
return; // mid-apply/remove; holder owns the transition
};
if crate::config::managed_config_identity_changed(Some(&team_id), None) {
tracing::info!(team_id = %team_id, "identity changed; purging the prior tenant's managed config");
if let Some(evicted) = crate::config::confirmed_team_switch_at(&home, &team_id) {
tracing::warn!(
team_id = %team_id,
evicted_principal = %evicted,
"identity changed; purging the prior tenant's managed config"
);
remove_managed_config_files(&home);
}
}
@ -961,6 +938,9 @@ pub enum SetupOutcome {
Installed,
/// The principal is valid but the server has no config for it.
NothingConfigured,
/// Nothing persisted by THIS run (another process held the apply lock, or the credential
/// vanished mid-fetch); re-running converges.
Skipped,
/// The fetch failed.
Failed(ManagedConfigError),
}
@ -1022,8 +1002,10 @@ pub async fn run_setup() -> SetupOutcome {
signature_rejected: true,
..
}) => SetupOutcome::Failed(ManagedConfigError::SignatureRejected),
// `served` (not `wrote`) so a lock skip by a concurrent writer — which
// is persisting the same config — isn't reported as "no config".
// A skip persisted nothing: not Installed (this run wrote nothing) nor NothingConfigured
// (the server does have config).
Ok(SyncOutcome { skipped: true, .. }) => SetupOutcome::Skipped,
// `served` (not `wrote`) so an unchanged re-fetch isn't reported as "no config".
Ok(SyncOutcome { served: true, .. }) => SetupOutcome::Installed,
Ok(_) => SetupOutcome::NothingConfigured,
Err(e) => SetupOutcome::Failed(e),

View file

@ -142,40 +142,28 @@ impl ManagedConfigResponse {
}
}
/// Result of [`apply_fetched`].
/// Result of applying a fetched managed-config response.
pub(super) enum ApplyOutcome {
/// Persisted the policy (`wrote` = at least one artifact written or removed), or
/// skipped under lock contention / a vanished credential (`wrote` = false); the
/// marker should be recorded either way. `signed_deployment_id` is the VERIFIED
/// payload's `deployment_id` (`None` when unsigned) — stronger than the body's,
/// which is omitted on the signed-empty response.
Applied {
wrote: bool,
signed_deployment_id: Option<String>,
},
/// Verification is active and the envelope did not verify — nothing was persisted.
/// The marker must NOT be recorded: it would claim a body that was never written.
/// Locked, persisted policy, recorded marker. `wrote` = ≥1 artifact written or removed.
Applied { wrote: bool },
/// Nothing persisted/marked: lock held by another process, or credential vanished mid-fetch.
Skipped,
/// Envelope failed verification — nothing persisted or marked.
SignatureRejected,
}
impl ApplyOutcome {
pub(super) fn wrote(&self) -> bool {
matches!(self, Self::Applied { wrote: true, .. })
matches!(self, Self::Applied { wrote: true })
}
pub(super) fn skipped(&self) -> bool {
matches!(self, Self::Skipped)
}
pub(super) fn signature_rejected(&self) -> bool {
matches!(self, Self::SignatureRejected)
}
pub(super) fn signed_deployment_id(&self) -> Option<&str> {
match self {
Self::Applied {
signed_deployment_id,
..
} => signed_deployment_id.as_deref(),
Self::SignatureRejected => None,
}
}
}
/// A fetched envelope that passed verification: the sidecar to persist, plus its

View file

@ -321,3 +321,75 @@ async fn send_with_retry_escaping_pool_combinator_behavior() {
"stops at the first success"
);
}
/// The sync marker is structurally separate from the artifact list — it must only ever
/// be removed by the dedicated post-loop step in `remove_managed_config_files`.
#[test]
fn marker_is_not_a_managed_artifact() {
assert!(
!MANAGED_ARTIFACT_FILES.contains(&xai_grok_config::MANAGED_CONFIG_CACHE_FILE),
"the marker must be removed last, never as part of the artifact loop"
);
// Pin the composed contents: the purge loops, tmp-prefix sweep, and eviction all
// derive from these names, so a constant silently changing value would re-point
// them all at once.
assert_eq!(
MANAGED_ARTIFACT_FILES,
[
"managed_config.toml",
"requirements.toml",
"managed_config.sig.json"
],
"the artifact list is load-bearing for every derived loop; change it deliberately"
);
}
/// Error prefixes re-arm the detector like crash prefixes: when an artifact removal
/// FAILS, the marker must survive, so the next start re-runs the purge instead of
/// leaving the prior tenant's policy live with the detector disarmed.
#[cfg(unix)]
#[test]
fn purge_keeps_marker_when_an_artifact_removal_fails() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
for name in MANAGED_ARTIFACT_FILES {
std::fs::write(home.join(name), "x").unwrap();
}
std::fs::write(home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE), "{}").unwrap();
// Make one artifact unremovable: squat it with a dir whose read-only subdir
// holds a file — `remove_dir_all` can't unlink inside the read-only subdir.
let squat = home.join("requirements.toml");
std::fs::remove_file(&squat).unwrap();
let locked_subdir = squat.join("locked");
std::fs::create_dir_all(&locked_subdir).unwrap();
std::fs::write(locked_subdir.join("pin"), "x").unwrap();
let readonly = std::fs::Permissions::from_mode(0o555);
std::fs::set_permissions(&locked_subdir, readonly).unwrap();
if std::fs::remove_dir_all(&squat).is_ok() {
// The fault can't be injected: read-only perms don't block removal (root/CI edge).
eprintln!("skipping: permissions not enforced (running as root?)");
return;
}
remove_managed_config_files(home);
assert!(
home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
.exists(),
"a failed artifact removal must keep the marker (detector stays armed)"
);
// Clear the fault: the next purge converges and only then drops the marker.
std::fs::set_permissions(&locked_subdir, std::fs::Permissions::from_mode(0o755)).unwrap();
remove_managed_config_files(home);
for name in MANAGED_ARTIFACT_FILES {
assert!(!home.join(name).exists(), "{name} must be purged");
}
assert!(
!home
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
.exists(),
"with every artifact removed, the marker goes last"
);
}

View file

@ -432,19 +432,18 @@ pub(crate) async fn spawn_session_actor(
chat_state_event_tx,
tokio_util::sync::CancellationToken::new(),
);
if !initial_prompt_texts.is_empty()
if (!initial_prompt_texts.is_empty()
|| initial_total_tokens > 0
|| initial_last_compaction.is_some()
|| initial_last_compaction.is_some())
&& let Some(mut snap) = chat_state_handle.snapshot().await
{
if let Some(mut snap) = chat_state_handle.snapshot().await {
snap.prompt_index = initial_prompt_texts.len();
snap.prompt_texts = initial_prompt_texts;
if initial_total_tokens > 0 {
snap.total_tokens = initial_total_tokens;
}
snap.last_compaction_prompt_index = initial_last_compaction;
chat_state_handle.restore_snapshot(snap);
snap.prompt_index = initial_prompt_texts.len();
snap.prompt_texts = initial_prompt_texts;
if initial_total_tokens > 0 {
snap.total_tokens = initial_total_tokens;
}
snap.last_compaction_prompt_index = initial_last_compaction;
chat_state_handle.restore_snapshot(snap);
}
chat_state_handle.update_credentials(credentials);
let state = TokioMutex::new(State {

View file

@ -898,9 +898,7 @@ impl SessionActor {
.id, decision = "deny", source = "plan_mode", wait_ms = 0_i64,
)
.in_scope(|| {});
let msg = match plan_gate {
_ => self.plan_mode_edit_rejected_message().await,
};
let msg = self.plan_mode_edit_rejected_message().await;
self.handle_tool_not_executed(&call.id, &tool_call_id, msg)
.await?;
return Ok(Err(ToolLoop::Continue));

View file

@ -492,15 +492,14 @@ pub(super) fn available_commands(
}))
.meta(meta.clone())
};
// Always advertise the qualified name for plugin skills.
// Also advertise the bare name if it doesn't collide.
if bare_collides {
vec![make_entry(qualified)]
} else {
// No collision — bare name only. The qualified form
// would just duplicate the entry in autocomplete.
vec![make_entry(s.name.clone())]
let mut entries = Vec::new();
if bare_collides || s.plugin_name.is_some() {
entries.push(make_entry(qualified));
}
if !bare_collides {
entries.push(make_entry(s.name.clone()));
}
entries
}
};
entries
@ -1900,6 +1899,13 @@ mod tests {
}
}
fn make_plugin_skill(plugin: &str, name: &str) -> SkillInfo {
SkillInfo {
plugin_name: Some(plugin.to_string()),
..make_scoped_skill(name, SkillScope::Plugin)
}
}
#[test]
fn resolve_ambiguous_bare_name_passes_through() {
// Two skills share the bare name "commit" in different scopes.
@ -1999,6 +2005,88 @@ mod tests {
assert!(names.contains(&"deploy"));
}
#[test]
fn available_commands_qualifies_plugin_skill_colliding_with_client_builtin() {
let skills = vec![make_plugin_skill("acme", "login")];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"acme:login"),
"plugin skill must be reachable under its qualified name, got: {names:?}"
);
let qualified = commands.iter().find(|c| c.name == "acme:login").unwrap();
assert!(
qualified.meta.is_some(),
"qualified plugin entry must carry skill meta (scope + path)"
);
let bare = commands.iter().find(|c| c.name == "login").unwrap();
assert!(bare.meta.is_some(), "bare 'login' here is the plugin skill");
}
#[test]
fn available_commands_offers_bare_and_qualified_for_noncolliding_plugin_skill() {
let skills = vec![make_plugin_skill("acme", "deploy")];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"acme:deploy"),
"expected qualified entry, got: {names:?}"
);
assert!(
names.contains(&"deploy"),
"expected bare convenience entry, got: {names:?}"
);
}
#[test]
fn available_commands_plugin_skill_colliding_with_shell_builtin_is_qualified_only() {
let skills = vec![make_plugin_skill("acme", "compact")];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"acme:compact"),
"expected qualified entry, got: {names:?}"
);
let compact_entries: Vec<_> = commands.iter().filter(|c| c.name == "compact").collect();
assert_eq!(compact_entries.len(), 1, "exactly one bare 'compact' entry");
assert!(
compact_entries[0].meta.is_none(),
"bare 'compact' must be the builtin, not the plugin skill"
);
}
#[test]
fn available_commands_two_plugins_same_bare_name_qualify_both() {
let skills = vec![
make_plugin_skill("acme", "login"),
make_plugin_skill("globex", "login"),
];
let commands = available_commands(&skills, all_gated());
let names: Vec<&str> = commands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"acme:login"), "got: {names:?}");
assert!(names.contains(&"globex:login"), "got: {names:?}");
assert!(
!names.contains(&"login"),
"bare 'login' must not be advertised when two plugins collide, got: {names:?}"
);
}
#[test]
fn resolve_qualified_plugin_skill_name() {
let skills = vec![make_plugin_skill("acme", "login")];
let outcome = resolve(
vec![text_block("/acme:login now")],
&skills,
all_gated(),
SkillSlashRewrite::default(),
)
.unwrap_err();
let skill = first_skill(outcome);
assert_eq!(skill.name, "acme:login");
assert_eq!(skill.args, "now");
}
#[test]
fn resolve_qualified_builtin_colliding_skill() {
// A skill named "compact" collides with the builtin.

View file

@ -43,17 +43,7 @@ pub fn facet_registry() -> &'static FacetRegistry {
/// Hard-off in release builds so they can't enable the
/// conversations lane via env.
pub fn conversations_lane_enabled() -> bool {
if true {
return false;
}
std::env::var("GROK_SESSION_LIST_CONVERSATIONS")
.ok()
.is_some_and(|v| {
!matches!(
v.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "off" | "no"
)
})
false
}
/// Env lane (desktop `GROK_SESSION_LIST_CONVERSATIONS`) OR process-wide
/// `--chat` (`GROK_CHAT_MODE`); hard-off in release builds.
@ -742,7 +732,7 @@ mod tests {
let _on = xai_grok_test_support::EnvGuard::set(GROK_CHAT_MODE_ENV, "1");
let req = parse_list_req(&raw).expect("parse");
let parsed = ParsedMeta::parse(req.meta.as_ref());
let expected = if false { "chat" } else { "build" };
let expected = "build";
assert_eq!(
parsed.facet_filters.get(KIND_FACET_KEY),
Some(&vec![serde_json::json!(expected)])