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
|
|
@ -1,14 +1,13 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-shell"
|
||||
version = "0.1.220-alpha.4"
|
||||
version = "0.2.101"
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
unstable = []
|
||||
default-bazel = [
|
||||
]
|
||||
default-bazel = []
|
||||
|
||||
[dependencies]
|
||||
dunce = { workspace = true }
|
||||
|
|
@ -184,13 +183,9 @@ windows = { workspace = true }
|
|||
criterion = { workspace = true }
|
||||
filetime = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
xai-grok-memory = { workspace = true, features = [] }
|
||||
xai-grok-workspace = { workspace = true, features = [] }
|
||||
# Feature-unification applies the signing seam to the lib when building tests, so
|
||||
# integration tests can inject a throwaway trusted key; production builds exclude
|
||||
# dev-deps and compile the seam out.
|
||||
xai-grok-memory = { workspace = true, features = ["test-support"] }
|
||||
xai-grok-workspace = { workspace = true, features = ["test-support"] }
|
||||
xai-grok-config = { workspace = true, features = [] }
|
||||
# Ed25519 signing for the managed-config signature tests.
|
||||
ring = { workspace = true }
|
||||
tar = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
|
@ -199,7 +194,7 @@ semver = { workspace = true }
|
|||
serial_test = { workspace = true }
|
||||
# Test-only builds expose `env::EnvVarGuard` and the cpu_profile test seams to
|
||||
# this crate's test targets.
|
||||
xai-grok-shell-base = { workspace = true, features = [] }
|
||||
xai-grok-shell-base = { workspace = true, features = ["test-support"] }
|
||||
xai-grok-test-support = { workspace = true, features = [] }
|
||||
xai-test-utils = { workspace = true }
|
||||
# `test-util` enables `#[tokio::test(start_paused = true)]` (paused virtual
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1099,7 +1099,6 @@ async fn handle_workspace_start(
|
|||
upload_queue_enabled,
|
||||
project_lsp_trusted,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(¤t_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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
//! Core end-to-end KEYED managed-config tests — verified persist, rejected
|
||||
//! persist-nothing, and the stripped-sidecar refusal. The harness (and the
|
||||
//! seam/serial constraints every test here must follow) lives in
|
||||
//! `signed_managed_config/common.rs`.
|
||||
//!
|
||||
//! Placement rule: this binary pins the review-cited security claims
|
||||
//! (verify-persists / reject-persists-nothing / sidecar-deletion-refuses); new
|
||||
//! keyed scenarios go in `signed_managed_config_extended.rs` unless they alter
|
||||
//! one of those three claims.
|
||||
|
||||
#[path = "signed_managed_config/common.rs"]
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, forged_team_body, install_test_key, reset, signed_team_body,
|
||||
spawn_mock, team_identity, test_home, write_config, write_team_auth,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use xai_grok_config::signed_policy;
|
||||
|
||||
/// A rejected envelope persists NOTHING: the prior principal's files survive
|
||||
/// (verify-before-evict), no sidecar appears, and the marker is not rewritten.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn rejected_signature_persists_nothing_and_records_no_marker() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
// Prior trusted state: team-b's files + marker (as if synced earlier).
|
||||
std::fs::write(home.join("managed_config.toml"), "[cli]\nprior = true\n").unwrap();
|
||||
std::fs::write(home.join("requirements.toml"), "[features]\n").unwrap();
|
||||
xai_grok_shell::config::mark_managed_config_synced(xai_grok_shell::config::SyncMarker {
|
||||
principal: Some("team-b"),
|
||||
had_managed_config: true,
|
||||
had_requirements: true,
|
||||
key_fingerprint: None,
|
||||
fail_closed: false,
|
||||
});
|
||||
|
||||
let url = spawn_mock(forged_team_body(&kp, "team-007"));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("a rejected signature is a no-op, not a transport error");
|
||||
assert!(!wrote, "nothing may be persisted for a rejected envelope");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(home.join("managed_config.toml")).unwrap(),
|
||||
"[cli]\nprior = true\n",
|
||||
"verify-before-evict: the prior policy must survive the identity switch"
|
||||
);
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
assert!(
|
||||
!home.join("managed_config.sig.json").exists(),
|
||||
"no sidecar may be written for a rejected envelope"
|
||||
);
|
||||
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(
|
||||
v["principal"].as_str(),
|
||||
Some("team-b"),
|
||||
"the marker must not be rewritten for a rejected fetch: {marker}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A good envelope persists the policy files AND a sidecar that verifies over the
|
||||
/// exact on-disk bytes; the cache then reads fresh and the gate allows.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn verified_envelope_persists_policy_and_sidecar() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-007",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("a verified sync should succeed");
|
||||
assert!(wrote);
|
||||
|
||||
let on_disk_managed = std::fs::read_to_string(home.join("managed_config.toml")).unwrap();
|
||||
let on_disk_requirements = std::fs::read_to_string(home.join("requirements.toml")).unwrap();
|
||||
let sidecar: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(home.join("managed_config.sig.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let payload = signed_policy::verify_signed_payload(
|
||||
sidecar["signed_payload"].as_str().unwrap(),
|
||||
sidecar["signature"].as_str().unwrap(),
|
||||
&[("v1", &pubkey)],
|
||||
)
|
||||
.expect("the persisted sidecar must verify");
|
||||
assert_eq!(
|
||||
payload.managed_config.as_deref(),
|
||||
Some(on_disk_managed.as_str()),
|
||||
"the sidecar covers the exact on-disk managed_config bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
payload.requirements.as_deref(),
|
||||
Some(on_disk_requirements.as_str()),
|
||||
"the sidecar covers the exact on-disk requirements bytes"
|
||||
);
|
||||
assert!(payload.fail_closed, "the signed opt-in is carried");
|
||||
|
||||
assert!(
|
||||
!xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"a covered cache is not hard-stale"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"an intact verified policy must not be refused"
|
||||
);
|
||||
}
|
||||
|
||||
/// Deleting the sidecar under a fail-closed marker REFUSES at the gate (stripping it
|
||||
/// must not downgrade enforcement to the forgeable marker path); the refetch triggers
|
||||
/// fire so an online start self-heals.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn deleted_sidecar_under_fail_closed_marker_refuses_at_gate() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-007",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("initial sync should succeed");
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"the covered fail-closed policy is allowed"
|
||||
);
|
||||
|
||||
std::fs::remove_file(home.join("managed_config.sig.json")).unwrap();
|
||||
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"a stripped sidecar must trigger the session-start refetch"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_stale_for(&team_identity("team-007")),
|
||||
"the TIMER staleness sibling must fire too (background tick self-heal), even though the marker is timer-fresh"
|
||||
);
|
||||
let gate = xai_grok_shell::managed_config::managed_policy_gate();
|
||||
assert!(
|
||||
gate.is_err(),
|
||||
"a fail-closed policy without its sidecar must refuse offline"
|
||||
);
|
||||
assert!(
|
||||
gate.unwrap_err()
|
||||
.contains("Managed policy is required for this account"),
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keyed availability fix: after a fail_closed team-A install (signed sidecar + marker), an
|
||||
/// OFFLINE switch to team B previously read Compromised (the authentic sidecar is bound to A) and
|
||||
/// refused a legitimate switch. The gate's identity-change purge must shed team A's artifacts
|
||||
/// INCLUDING the sidecar, PERMIT team B, and leave the cache hard-stale so the next online start
|
||||
/// fetches team B's own policy.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn offline_team_switch_purges_sidecar_and_permits_new_team() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-a",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team A keyed sync should succeed");
|
||||
assert!(
|
||||
home.join("managed_config.sig.json").exists(),
|
||||
"the keyed sync persists a sidecar"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"team A's verified fail_closed policy must start"
|
||||
);
|
||||
|
||||
// Switch the signed-in team to B; the gate is sync, so no fetch can rebind first.
|
||||
write_team_auth(&home, "team-b");
|
||||
|
||||
// The bug this fixes: without the purge, team B evaluates against team A's
|
||||
// foreign-bound sidecar → Compromised → a legitimate switch refused startup.
|
||||
assert!(
|
||||
xai_grok_shell::config::managed_policy_compromised_for(&team_identity("team-b")),
|
||||
"pre-purge, the foreign-bound sidecar must read compromised for team B"
|
||||
);
|
||||
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"the gate must purge team A and permit the legitimate offline switch to team B"
|
||||
);
|
||||
for f in [
|
||||
"requirements.toml",
|
||||
"managed_config.toml",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.sig.json",
|
||||
] {
|
||||
assert!(
|
||||
!home.join(f).exists(),
|
||||
"{f} must be purged on the identity change"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-b")),
|
||||
"the purged cache must read hard-stale so the next online start fetches team B's policy"
|
||||
);
|
||||
}
|
||||
|
||||
/// A blank `team_id` in `auth.json` (a parse blip) over an authentic team-A-bound fail_closed
|
||||
/// sidecar: the blank→None filter resolves the identity to None, the marker principal backstops
|
||||
/// the signed binding (team-a vs team-a → Trusted), so the KEYED gate PERMITS — instead of
|
||||
/// binding to "" and refusing as Compromised — and nothing is purged.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn keyed_blank_team_id_is_not_refused_and_does_not_purge() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_team_body(
|
||||
&kp,
|
||||
"team-a",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team A keyed sync should succeed");
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"team A's verified fail_closed policy must start"
|
||||
);
|
||||
|
||||
// auth.json now carries a team principal with a BLANK team_id.
|
||||
write_team_auth(&home, "");
|
||||
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"a blank team_id must read as unknown, not a foreign binding that reads compromised"
|
||||
);
|
||||
for f in [
|
||||
"requirements.toml",
|
||||
"managed_config.toml",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.sig.json",
|
||||
] {
|
||||
assert!(
|
||||
home.join(f).exists(),
|
||||
"{f} must be retained on a blank team_id (a parse blip is not an identity change)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
//! Shared harness for the KEYED managed-config integration tests: a test-only
|
||||
//! signing seam injects a throwaway trusted key so the real
|
||||
//! sync → verify → persist → gate paths run with verification ACTIVE (the dark
|
||||
//! behavior is covered by `team_managed_config.rs`).
|
||||
//!
|
||||
//! Every test MUST be `#[serial]` and install its own seam keys first: the test
|
||||
//! binary shares one process-global `GROK_HOME`, process env, and key override.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use base64::Engine as _;
|
||||
use xai_grok_config::signed_policy::{self, SignedPayload};
|
||||
|
||||
pub const MANAGED: &str = "[cli]\ntheme = \"dark\"\n";
|
||||
pub const REQUIREMENTS_FAIL_CLOSED: &str = "fail_closed = true\n[features]\nweb_fetch = false\n";
|
||||
/// Far-future expiry — envelopes in these tests never expire.
|
||||
pub const TEST_EXPIRES_AT: u64 = 4_000_000_000;
|
||||
/// The sole trusted key id: [`install_test_key`] installs it and [`sign_envelope`]
|
||||
/// signs under it, so the two can't drift.
|
||||
pub const TEST_KEY_ID: &str = "v1";
|
||||
|
||||
/// Shared temp dir used as GROK_HOME for the whole test binary (the grok_home
|
||||
/// `OnceLock` only allows one value per process); scrubs the env this suite
|
||||
/// depends on before any test thread reads it.
|
||||
pub fn test_home() -> &'static PathBuf {
|
||||
static HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||
HOME.get_or_init(|| {
|
||||
let path = tempfile::TempDir::new().unwrap().keep();
|
||||
// SAFETY: set once at init before other threads read the vars.
|
||||
unsafe {
|
||||
std::env::set_var("GROK_HOME", &path);
|
||||
for var in [
|
||||
"GROK_DEPLOYMENT_KEY",
|
||||
"GROK_MANAGED_CONFIG",
|
||||
"GROK_DEPLOYMENT_CONFIG_REFRESH_INTERVAL_SECS",
|
||||
"GROK_DEPLOYMENT_CONFIG_CACHE_TTL_SECS",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
] {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
std::env::set_var("GROK_DEPLOYMENT_CONFIG_BACKOFF_MS", "10");
|
||||
}
|
||||
path
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reset(home: &std::path::Path) {
|
||||
for f in [
|
||||
"config.toml",
|
||||
"auth.json",
|
||||
"managed_config.toml",
|
||||
"requirements.toml",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.lock",
|
||||
"managed_config.sig.json",
|
||||
] {
|
||||
let _ = std::fs::remove_file(home.join(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal mock deployment-config server serving `body` to every request.
|
||||
pub fn spawn_mock(body: String) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
// Drain the request headers before responding.
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim_end().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
let _ = stream.flush();
|
||||
}
|
||||
});
|
||||
format!("http://{addr}/deployment/config")
|
||||
}
|
||||
|
||||
pub fn write_config(home: &std::path::Path, managed_config_url: &str) {
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!("[endpoints]\nmanaged_config_url = \"{managed_config_url}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// [`write_config`] plus a `deployment_key` (dead-code-allowed: compiled into
|
||||
/// both binaries, called by one).
|
||||
#[allow(dead_code)]
|
||||
pub fn write_dk_config(home: &std::path::Path, managed_config_url: &str, deployment_key: &str) {
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!(
|
||||
"[endpoints]\nmanaged_config_url = \"{managed_config_url}\"\ndeployment_key = \"{deployment_key}\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn write_team_auth(home: &std::path::Path, team_id: &str) {
|
||||
let scope = xai_grok_shell::auth::GrokComConfig::default().auth_scope();
|
||||
let auth = serde_json::json!({
|
||||
scope: {
|
||||
"key": "team-session-token",
|
||||
"auth_mode": "oidc",
|
||||
"create_time": "2026-01-01T00:00:00Z",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
"user_id": "user-1",
|
||||
"principal_type": "Team",
|
||||
"team_id": team_id,
|
||||
}
|
||||
});
|
||||
std::fs::write(home.join("auth.json"), auth.to_string()).unwrap();
|
||||
}
|
||||
|
||||
/// A fresh Ed25519 keypair plus its raw public key, installed as the sole trusted
|
||||
/// key ([`TEST_KEY_ID`]) via the test seam.
|
||||
pub fn install_test_key() -> (ring::signature::Ed25519KeyPair, Vec<u8>) {
|
||||
use ring::signature::KeyPair as _;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
|
||||
let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
|
||||
let pubkey = kp.public_key().as_ref().to_vec();
|
||||
signed_policy::test_seam::set_embedded_keys(&[(TEST_KEY_ID, &pubkey)]);
|
||||
assert!(
|
||||
signed_policy::verification_active(),
|
||||
"the seam must arm verification"
|
||||
);
|
||||
(kp, pubkey)
|
||||
}
|
||||
|
||||
/// Serialize → sign → base64: the one `signatures[]` entry for `payload`, signed
|
||||
/// by `kp` under the payload's own `key_id` (the untrusted outer hint can't drift
|
||||
/// from the signed one).
|
||||
pub fn sign_envelope(
|
||||
kp: &ring::signature::Ed25519KeyPair,
|
||||
payload: &SignedPayload,
|
||||
) -> serde_json::Value {
|
||||
let signed_payload = serde_json::to_string(payload).unwrap();
|
||||
let signature = base64::engine::general_purpose::STANDARD
|
||||
.encode(kp.sign(signed_payload.as_bytes()).as_ref());
|
||||
serde_json::json!({
|
||||
"signed_payload": signed_payload,
|
||||
"signature": signature,
|
||||
"key_id": payload.key_id.as_str(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A team deployment-config response signed by `kp` under [`TEST_KEY_ID`]. The
|
||||
/// body's legacy fields mirror the payload exactly (the client rejects a divergence).
|
||||
pub fn signed_team_body(
|
||||
kp: &ring::signature::Ed25519KeyPair,
|
||||
team_id: &str,
|
||||
managed: Option<&str>,
|
||||
requirements: Option<&str>,
|
||||
) -> String {
|
||||
let payload = SignedPayload {
|
||||
version: prod_mc_cli_chat_proxy_types::SIGNED_PAYLOAD_VERSION,
|
||||
deployment_id: None,
|
||||
team_id: Some(team_id.to_owned()),
|
||||
managed_config: managed.map(str::to_owned),
|
||||
requirements: requirements.map(str::to_owned),
|
||||
fail_closed: requirements.is_some_and(xai_grok_config::fail_closed_flag_from_str),
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
key_id: TEST_KEY_ID.into(),
|
||||
};
|
||||
serde_json::json!({
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": team_id,
|
||||
"managed_config": managed,
|
||||
"requirements": requirements,
|
||||
"signatures": [sign_envelope(kp, &payload)],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A [`signed_team_body`] (managed config only) with the signature corrupted —
|
||||
/// valid base64, wrong bytes — so the verifier must reject the envelope.
|
||||
pub fn forged_team_body(kp: &ring::signature::Ed25519KeyPair, team_id: &str) -> String {
|
||||
let mut body: serde_json::Value =
|
||||
serde_json::from_str(&signed_team_body(kp, team_id, Some(MANAGED), None)).unwrap();
|
||||
body["signatures"][0]["signature"] = base64::engine::general_purpose::STANDARD
|
||||
.encode([0u8; 64])
|
||||
.into();
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
pub fn team_identity(id: &str) -> xai_grok_shell::config::ServingIdentity {
|
||||
xai_grok_shell::config::ServingIdentity::Team(id.to_owned())
|
||||
}
|
||||
|
||||
/// True when `path` reads despite `chmod 000` (root / DAC bypass): chmod-based
|
||||
/// tests must then skip LOUDLY — a silent return would pass forever. CI runners
|
||||
/// are assumed unprivileged; the shared guard keeps skips greppable.
|
||||
#[cfg(unix)]
|
||||
#[allow(dead_code)]
|
||||
pub fn skip_as_root(path: &std::path::Path, test: &str) -> bool {
|
||||
let skip = std::fs::read_to_string(path).is_ok();
|
||||
if skip {
|
||||
eprintln!("{test}: skipping — chmod unreadability not enforced (running as root?)");
|
||||
}
|
||||
skip
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
//! Extended KEYED managed-config scenarios. Harness + seam/serial constraints:
|
||||
//! `signed_managed_config/common.rs`.
|
||||
//!
|
||||
//! Placement rule: new keyed scenarios land HERE; `signed_managed_config.rs`
|
||||
//! stays fixed to the review-cited security claims (verify-persists /
|
||||
//! reject-persists-nothing / sidecar-deletion-refuses).
|
||||
|
||||
#[path = "signed_managed_config/common.rs"]
|
||||
mod common;
|
||||
|
||||
#[cfg(unix)]
|
||||
use common::skip_as_root;
|
||||
use common::{
|
||||
MANAGED, REQUIREMENTS_FAIL_CLOSED, TEST_EXPIRES_AT, TEST_KEY_ID, forged_team_body,
|
||||
install_test_key, reset, sign_envelope, signed_team_body, spawn_mock, team_identity, test_home,
|
||||
write_config, write_dk_config, write_team_auth,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use xai_grok_config::signed_policy::{self, SignedPayload};
|
||||
|
||||
/// The healthy fail-closed starting state the tamper/heal scenarios mutate;
|
||||
/// the mock keeps serving the same body, so a healing sync can refetch it.
|
||||
async fn sync_fail_closed_policy(home: &std::path::Path, kp: &ring::signature::Ed25519KeyPair) {
|
||||
let url = spawn_mock(signed_team_body(
|
||||
kp,
|
||||
"team-007",
|
||||
Some(MANAGED),
|
||||
Some(REQUIREMENTS_FAIL_CLOSED),
|
||||
));
|
||||
write_config(home, &url);
|
||||
write_team_auth(home, "team-007");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("initial sync should succeed");
|
||||
assert!(xai_grok_shell::managed_config::managed_policy_gate().is_ok());
|
||||
}
|
||||
|
||||
/// The signed-empty deployment response: a `{}` body (no legacy fields) whose
|
||||
/// envelope binds ABSENCE to `deployment_id` — what the server serves for a
|
||||
/// provisioned key with no config row.
|
||||
fn signed_dk_empty_body(kp: &ring::signature::Ed25519KeyPair, deployment_id: &str) -> String {
|
||||
let payload = SignedPayload {
|
||||
version: prod_mc_cli_chat_proxy_types::SIGNED_PAYLOAD_VERSION,
|
||||
deployment_id: Some(deployment_id.to_owned()),
|
||||
team_id: None,
|
||||
managed_config: None,
|
||||
requirements: None,
|
||||
fail_closed: false,
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
key_id: TEST_KEY_ID.into(),
|
||||
};
|
||||
serde_json::json!({ "signatures": [sign_envelope(kp, &payload)] }).to_string()
|
||||
}
|
||||
|
||||
/// The marker principal for an applied signed-EMPTY dk response comes from the
|
||||
/// VERIFIED payload's deployment_id (the `{}` body carries none), so the gate's
|
||||
/// cross-tenant binding holds even on an unprovisioned dk machine.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn empty_dk_response_marker_binds_the_verified_deployment_id() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(signed_dk_empty_body(&kp, "dep-42"));
|
||||
write_dk_config(&home, &url, "dep-key-1");
|
||||
// No team auth: the empty dk body is applied (converges), not fallen through.
|
||||
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("signed-empty dk sync should succeed");
|
||||
assert!(!wrote, "nothing to write for an empty row");
|
||||
|
||||
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(
|
||||
v["principal"].as_str(),
|
||||
Some("dep-42"),
|
||||
"the marker must bind the VERIFIED deployment id: {marker}"
|
||||
);
|
||||
assert!(
|
||||
home.join("managed_config.sig.json").exists(),
|
||||
"the absence envelope is persisted"
|
||||
);
|
||||
assert!(xai_grok_shell::managed_config::managed_policy_gate().is_ok());
|
||||
}
|
||||
|
||||
/// A signature-rejected sync surfaces as failure in BOTH `grok setup` and the
|
||||
/// post-login sync — never as Installed/NoChange while nothing was persisted.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn rejected_signature_surfaces_as_setup_and_login_failure() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
let url = spawn_mock(forged_team_body(&kp, "team-007"));
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
|
||||
let outcome = xai_grok_shell::managed_config::run_setup().await;
|
||||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
xai_grok_shell::managed_config::SetupOutcome::Failed(
|
||||
xai_grok_shell::managed_config::ManagedConfigError::SignatureRejected
|
||||
)
|
||||
),
|
||||
"setup must surface the signature rejection, got {outcome:?}"
|
||||
);
|
||||
|
||||
let login = xai_grok_shell::managed_config::post_login_sync(None).await;
|
||||
assert_eq!(
|
||||
login,
|
||||
xai_grok_shell::managed_config::ManagedConfigSync::Failed,
|
||||
"post-login sync must report Failed, not NoChange"
|
||||
);
|
||||
}
|
||||
|
||||
/// A response that stops serving requirements deletes the on-disk file, and the
|
||||
/// NEW sidecar (written after the deletion) covers the absence — the converged cache
|
||||
/// reads fresh and the gate allows.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn withdrawn_requirements_is_deleted_and_covered_by_the_new_sidecar() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, pubkey) = install_test_key();
|
||||
|
||||
sync_fail_closed_policy(&home, &kp).await;
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
|
||||
let url_partial = spawn_mock(signed_team_body(&kp, "team-007", Some(MANAGED), None));
|
||||
write_config(&home, &url_partial);
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("withdrawing sync should succeed");
|
||||
assert!(wrote, "the deletion is a change");
|
||||
|
||||
assert!(
|
||||
!home.join("requirements.toml").exists(),
|
||||
"the withdrawn artifact is removed"
|
||||
);
|
||||
let sidecar: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(home.join("managed_config.sig.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let payload = signed_policy::verify_signed_payload(
|
||||
sidecar["signed_payload"].as_str().unwrap(),
|
||||
sidecar["signature"].as_str().unwrap(),
|
||||
&[(TEST_KEY_ID, &pubkey)],
|
||||
)
|
||||
.expect("the refreshed sidecar must verify");
|
||||
assert!(
|
||||
payload.requirements.is_none(),
|
||||
"the new sidecar covers the absence"
|
||||
);
|
||||
assert!(
|
||||
!xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"the converged, covered cache is not hard-stale"
|
||||
);
|
||||
assert!(xai_grok_shell::managed_config::managed_policy_gate().is_ok());
|
||||
}
|
||||
|
||||
/// A directory squatting at a signed artifact path reads COMPROMISED at the gate
|
||||
/// (not lenient-unreadable), and an online sync converges over it — clearing the
|
||||
/// directory, rewriting the file, and restoring enforcement.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn directory_squat_reads_compromised_and_online_sync_heals() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
sync_fail_closed_policy(&home, &kp).await;
|
||||
|
||||
// Dir-squat the enforced artifact (with a child, like a real squat).
|
||||
std::fs::remove_file(home.join("requirements.toml")).unwrap();
|
||||
std::fs::create_dir(home.join("requirements.toml")).unwrap();
|
||||
std::fs::write(home.join("requirements.toml").join("junk"), "x").unwrap();
|
||||
|
||||
let gate = xai_grok_shell::managed_config::managed_policy_gate();
|
||||
assert!(
|
||||
gate.is_err(),
|
||||
"a directory squat on a fail-closed policy must refuse offline"
|
||||
);
|
||||
// The gate verdict, not an incidental error; classification is unit-pinned
|
||||
// in signed_policy::directory_squat_is_tamper_not_unreadable.
|
||||
assert!(
|
||||
gate.unwrap_err()
|
||||
.contains("Managed policy is required for this account"),
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"the squat must trigger the refetch"
|
||||
);
|
||||
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("healing sync should succeed");
|
||||
assert!(wrote, "the healing sync must rewrite the squatted artifact");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(home.join("requirements.toml")).unwrap(),
|
||||
REQUIREMENTS_FAIL_CLOSED,
|
||||
"the served file replaces the squatting directory"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"enforcement is restored after the heal"
|
||||
);
|
||||
}
|
||||
|
||||
/// A sidecar read blip (chmod 000) is not tamper: the gate allows while the
|
||||
/// refetch trigger fires — mirroring the artifact-slot blip semantics.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn sidecar_read_blip_allows_session_and_triggers_refetch() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
sync_fail_closed_policy(&home, &kp).await;
|
||||
|
||||
let sidecar_path = home.join("managed_config.sig.json");
|
||||
std::fs::set_permissions(&sidecar_path, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
if skip_as_root(
|
||||
&sidecar_path,
|
||||
"sidecar_read_blip_allows_session_and_triggers_refetch",
|
||||
) {
|
||||
let _ = std::fs::set_permissions(&sidecar_path, std::fs::Permissions::from_mode(0o600));
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"a transient sidecar read blip must not refuse the session"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"the blip must trigger the refetch so the self-heal runs"
|
||||
);
|
||||
// Restore so the tempdir (and later tests) stay clean.
|
||||
std::fs::set_permissions(&sidecar_path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
|
||||
/// A directory squatting at the SIDECAR path refuses at the gate, and the online
|
||||
/// sync clears it — a bare rename would error forever.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn sidecar_directory_squat_refuses_then_online_sync_heals() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
let (kp, _pubkey) = install_test_key();
|
||||
|
||||
sync_fail_closed_policy(&home, &kp).await;
|
||||
|
||||
// Dir-squat the sidecar (with a child, like a real squat).
|
||||
let sidecar_path = home.join("managed_config.sig.json");
|
||||
std::fs::remove_file(&sidecar_path).unwrap();
|
||||
std::fs::create_dir(&sidecar_path).unwrap();
|
||||
std::fs::write(sidecar_path.join("junk"), "x").unwrap();
|
||||
|
||||
let gate = xai_grok_shell::managed_config::managed_policy_gate();
|
||||
assert!(
|
||||
gate.is_err(),
|
||||
"an unreadable (squatted) sidecar under a fail-closed marker must refuse offline"
|
||||
);
|
||||
// The gate verdict, not an incidental error; classification is unit-pinned
|
||||
// in signed_policy::sidecar_directory_squat_is_absence_not_a_blip.
|
||||
assert!(
|
||||
gate.unwrap_err()
|
||||
.contains("Managed policy is required for this account"),
|
||||
"the refusal is the managed-policy gate message"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::config::is_managed_config_hard_stale_for(&team_identity("team-007")),
|
||||
"the squat must trigger the refetch"
|
||||
);
|
||||
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("healing sync should succeed");
|
||||
assert!(
|
||||
sidecar_path.is_file(),
|
||||
"the rewrite must replace the squatting directory with a sidecar FILE"
|
||||
);
|
||||
// Under a fail-closed marker the gate requires an authentic sidecar, so
|
||||
// allowing here also pins that the healed sidecar verifies.
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"enforcement is restored after the heal"
|
||||
);
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ fn reset(home: &std::path::Path) {
|
|||
"auth.json",
|
||||
"managed_config.toml",
|
||||
"requirements.toml",
|
||||
"managed_config.sig.json",
|
||||
"managed_config_cache.json",
|
||||
"managed_config.lock",
|
||||
] {
|
||||
|
|
@ -97,6 +98,35 @@ fn spawn_mock(body: String) -> (String, Arc<Mutex<Vec<String>>>) {
|
|||
(url, auths)
|
||||
}
|
||||
|
||||
/// Like [`spawn_mock_seq`] but sleeps `delay` before each response (for mid-fetch races).
|
||||
fn spawn_mock_delayed(body: String, delay: std::time::Duration) -> MockHandle {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
|
||||
let counter = count.clone();
|
||||
let auths: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen_auths = auths.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
if let Some(auth) = read_request_auth(&mut stream) {
|
||||
seen_auths.lock().unwrap().push(auth);
|
||||
}
|
||||
{
|
||||
*counter.lock().unwrap() += 1;
|
||||
}
|
||||
std::thread::sleep(delay);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}/v1/deployment-config"), count, auths)
|
||||
}
|
||||
|
||||
/// `(url, request_count, authorization_headers_in_order)`.
|
||||
type MockHandle = (String, Arc<Mutex<usize>>, Arc<Mutex<Vec<String>>>);
|
||||
|
||||
|
|
@ -332,6 +362,81 @@ async fn team_sync_writes_files() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A directory squatting at the MARKER path must not permanently disarm the staleness
|
||||
/// detector: the atomic marker write would fail onto it on every sync, forever. The
|
||||
/// locked apply clears the squat (same rule as the sidecar) and records the sync.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn marker_dir_squat_is_cleared_and_marker_written() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let (url, _auths) = spawn_mock(team_config_body());
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-007");
|
||||
|
||||
// Dir-squat the marker (with a child, like a real squat).
|
||||
let marker_path = home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE);
|
||||
std::fs::create_dir(&marker_path).unwrap();
|
||||
std::fs::write(marker_path.join("junk"), "x").unwrap();
|
||||
|
||||
let wrote = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
assert!(
|
||||
wrote,
|
||||
"the policy files are written despite the marker squat"
|
||||
);
|
||||
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"the apply must replace the squatting directory with the marker FILE"
|
||||
);
|
||||
let marker = std::fs::read_to_string(&marker_path).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(
|
||||
v["principal"].as_str(),
|
||||
Some("team-007"),
|
||||
"the recorded marker must describe this sync: {marker}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A whitespace-padded `team_id` in `auth.json` is one identity end-to-end: the serving
|
||||
/// identity and the recorded marker are trimmed, and re-syncing with the padded id is the
|
||||
/// same tenant (no eviction, no confirmed switch).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn padded_team_id_is_one_identity() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let (url, _auths) = spawn_mock(team_config_body());
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, " team-007 ");
|
||||
|
||||
assert_eq!(
|
||||
xai_grok_shell::managed_config::current_serving_identity(),
|
||||
team_identity("team-007"),
|
||||
"the serving identity must be the trimmed team id"
|
||||
);
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("sync should succeed");
|
||||
let marker =
|
||||
std::fs::read_to_string(home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(&marker).unwrap();
|
||||
assert_eq!(
|
||||
v["principal"].as_str(),
|
||||
Some("team-007"),
|
||||
"the marker stores the trimmed identity: {marker}"
|
||||
);
|
||||
assert_eq!(
|
||||
xai_grok_config::confirmed_team_switch("team-007"),
|
||||
None,
|
||||
"padding is not a tenant switch"
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching the active team must not keep enforcing the prior team's policy: after B syncs,
|
||||
/// A's artifacts are evicted and the marker records B served nothing. Fail-open.
|
||||
#[tokio::test]
|
||||
|
|
@ -340,7 +445,9 @@ async fn team_switch_evicts_prior_teams_policy() {
|
|||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
// Team A serves both managed_config and requirements.
|
||||
// Team A serves both managed_config and requirements. A leftover sidecar (from an
|
||||
// earlier signing build; verification is inactive here) must also be evicted, or a
|
||||
// later signing build would read A's foreign-bound sidecar against B's identity.
|
||||
let (url_a, _auths_a) = spawn_mock(team_config_body());
|
||||
write_config(&home, &url_a);
|
||||
write_team_auth(&home, "team-a");
|
||||
|
|
@ -349,6 +456,7 @@ async fn team_switch_evicts_prior_teams_policy() {
|
|||
.expect("team A sync should succeed");
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
assert!(home.join("managed_config.toml").exists());
|
||||
std::fs::write(home.join("managed_config.sig.json"), "{}").unwrap();
|
||||
|
||||
// Switch to team B, whose server returns a row (team_id) but no artifacts.
|
||||
let body_b = serde_json::json!({
|
||||
|
|
@ -376,6 +484,10 @@ async fn team_switch_evicts_prior_teams_policy() {
|
|||
!home.join("managed_config.toml").exists(),
|
||||
"team A's managed_config must be evicted on the switch to team B"
|
||||
);
|
||||
assert!(
|
||||
!home.join("managed_config.sig.json").exists(),
|
||||
"team A's stale sidecar must be evicted on the switch to team B"
|
||||
);
|
||||
|
||||
// The marker is now team B's and must not claim B served A's artifacts.
|
||||
let marker = std::fs::read_to_string(home.join("managed_config_cache.json")).unwrap();
|
||||
|
|
@ -974,18 +1086,15 @@ async fn identity_change_permits_offline_team_switch_and_purges_prior_team() {
|
|||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"a legitimate offline team switch must not fail closed"
|
||||
);
|
||||
assert!(
|
||||
!home.join("requirements.toml").exists(),
|
||||
"team A's enforced requirements must be purged on the switch"
|
||||
);
|
||||
assert!(
|
||||
!home.join("managed_config.toml").exists(),
|
||||
"team A's managed_config must be purged on the switch"
|
||||
);
|
||||
assert!(
|
||||
!home.join("managed_config_cache.json").exists(),
|
||||
"team A's sync marker must be purged on the switch"
|
||||
);
|
||||
for f in xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES
|
||||
.into_iter()
|
||||
.chain([xai_grok_config::MANAGED_CONFIG_CACHE_FILE])
|
||||
{
|
||||
assert!(
|
||||
!home.join(f).exists(),
|
||||
"team A's {f} must be purged on the switch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate purge takes the managed-config lock best-effort and SKIPS on contention (the holder
|
||||
|
|
@ -1055,6 +1164,66 @@ async fn gate_purge_skips_while_lock_contended() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A TRANSIENT lock holder must not turn an offline team switch into a skipped purge:
|
||||
/// the purge retries the lock once after 100ms (`PURGE_LOCK_RETRY_DELAY`), so a holder
|
||||
/// that releases within that window (~20ms here) is absorbed and the SAME gate call
|
||||
/// purges team A on the second attempt.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn gate_purge_retries_past_a_transient_lock_holder() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": "team-a",
|
||||
"managed_config": TEAM_MANAGED,
|
||||
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
||||
})
|
||||
.to_string();
|
||||
let (url, _auths) = spawn_mock(body);
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team A sync should succeed");
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
|
||||
write_team_auth(&home, "team-b");
|
||||
|
||||
// Acquire the flock BEFORE the gate call, then hand it to a helper that releases
|
||||
// it ~20ms in — inside the purge's retry window.
|
||||
let lock = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(home.join("managed_config.lock"))
|
||||
.unwrap();
|
||||
lock.lock().unwrap();
|
||||
let holder = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
drop(lock); // releases the flock
|
||||
});
|
||||
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"a pure identity mismatch never refuses, purged or not"
|
||||
);
|
||||
holder.join().unwrap();
|
||||
|
||||
assert!(
|
||||
!home.join("requirements.toml").exists(),
|
||||
"one gate call must absorb the transient holder via the retry and purge team A"
|
||||
);
|
||||
assert!(
|
||||
!home
|
||||
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"team A's marker goes with the retried purge"
|
||||
);
|
||||
}
|
||||
|
||||
/// A blank `team_id` in `auth.json` (a parse blip / malformed write) is "unknown", not a
|
||||
/// distinct identity: the gate must NOT fail closed and the purge must NOT shed team A's
|
||||
/// policy. Guards the blank→None map in `active_team_id_any_expiry` and the detector's
|
||||
|
|
@ -1095,6 +1264,13 @@ async fn blank_team_id_neither_fails_closed_nor_purges() {
|
|||
home.join("managed_config_cache.json").exists(),
|
||||
"the team A marker must be retained on a blank team_id"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
xai_grok_shell::managed_config::current_serving_identity(),
|
||||
ServingIdentity::None
|
||||
),
|
||||
"a blank team_id must resolve to no identity, not Team(\"\") (spurious refetch input)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The session-start gate reads no env: `GROK_MANAGED_CONFIG_FAIL_CLOSED=0` must NOT disarm a fail_closed
|
||||
|
|
@ -1394,8 +1570,9 @@ async fn lock_contention_does_not_fall_through_to_team() {
|
|||
assert_eq!(*count.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
/// `grok setup` with config served but the lock held by another writer reports
|
||||
/// Installed (the holder is persisting it), not NothingConfigured.
|
||||
/// `grok setup` with config served but the lock held by another writer reports the
|
||||
/// skip: not Installed (THIS run persisted nothing) and not NothingConfigured (the
|
||||
/// server does have config).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn setup_lock_skip_is_not_reported_as_no_config() {
|
||||
|
|
@ -1421,9 +1598,10 @@ async fn setup_lock_skip_is_not_reported_as_no_config() {
|
|||
assert!(
|
||||
matches!(
|
||||
outcome,
|
||||
xai_grok_shell::managed_config::SetupOutcome::Installed
|
||||
xai_grok_shell::managed_config::SetupOutcome::Skipped
|
||||
),
|
||||
"served config with the lock held must not report NothingConfigured"
|
||||
"a lock skip persisted nothing: it must report Skipped, not Installed or \
|
||||
NothingConfigured, got {outcome:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1848,3 +2026,225 @@ async fn deploy_key_machine_never_gate_purges_on_team_switch() {
|
|||
"the sync marker must survive too — the key, not the team, owns this machine's policy"
|
||||
);
|
||||
}
|
||||
|
||||
/// For every on-disk state a crashed purge can leave (each proper prefix of the removal
|
||||
/// order), the marker is still present, the detector still fires for the new team, and a
|
||||
/// later purge converges. The order itself is pinned by `marker_is_not_a_managed_artifact`
|
||||
/// plus the fault-injection unit test.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn purge_crash_prefixes_stay_armed_and_converge() {
|
||||
let home = test_home().clone();
|
||||
let artifacts = xai_grok_shell::managed_config::MANAGED_ARTIFACT_FILES;
|
||||
// 0..=len: every proper prefix of the 4-step removal order, up to and including
|
||||
// "all artifacts removed, marker still present" (a crash right before the marker step).
|
||||
for prefix_len in 0..=artifacts.len() {
|
||||
reset(&home);
|
||||
let body = serde_json::json!({
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": "team-a",
|
||||
"managed_config": TEAM_MANAGED,
|
||||
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
||||
})
|
||||
.to_string();
|
||||
let (url, _auths) = spawn_mock(body);
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("team-a sync should succeed");
|
||||
|
||||
// Simulate a purge crashed after removing only this prefix.
|
||||
for name in &artifacts[..prefix_len] {
|
||||
let _ = std::fs::remove_file(home.join(name));
|
||||
}
|
||||
assert!(
|
||||
home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"marker must outlive every artifact prefix (prefix_len={prefix_len})"
|
||||
);
|
||||
|
||||
// Team B arrives offline: the detector must still confirm and the purge converge.
|
||||
write_team_auth(&home, "team-b");
|
||||
assert_eq!(
|
||||
xai_grok_config::confirmed_team_switch("team-b").as_deref(),
|
||||
Some("team-a"),
|
||||
"detector must stay armed after a crash prefix (prefix_len={prefix_len})"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"offline switch over a crash prefix must not refuse (prefix_len={prefix_len})"
|
||||
);
|
||||
for name in artifacts {
|
||||
assert!(
|
||||
!home.join(name).exists(),
|
||||
"{name} must be purged (prefix_len={prefix_len})"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!home
|
||||
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"the converged purge drops the marker last (prefix_len={prefix_len})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker written under the apply lock by the holder only: lock-contended apply records nothing.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn contended_sync_writes_no_marker() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"deployment_id": serde_json::Value::Null,
|
||||
"team_id": "team-a",
|
||||
"managed_config": TEAM_MANAGED,
|
||||
"requirements": TEAM_REQUIREMENTS,
|
||||
})
|
||||
.to_string();
|
||||
let (url, auths) = spawn_mock(body);
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-a");
|
||||
|
||||
// Hold the managed-config flock across the sync: apply skips, so nothing is persisted.
|
||||
let lock = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(home.join("managed_config.lock"))
|
||||
.unwrap();
|
||||
lock.lock().unwrap();
|
||||
let synced = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("sync should succeed (skip, not error)");
|
||||
assert!(!synced, "a lock-contended apply must not report a write");
|
||||
lock.unlock().unwrap();
|
||||
|
||||
// Positive control: the FETCH happened (only the apply was skipped), so the
|
||||
// no-marker assertions below can't pass vacuously on a sync that never ran.
|
||||
assert!(
|
||||
!auths.lock().unwrap().is_empty(),
|
||||
"the fetch must have reached the server"
|
||||
);
|
||||
assert!(
|
||||
!home
|
||||
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"a contended sync must not write a marker for files it never persisted"
|
||||
);
|
||||
assert!(!home.join("requirements.toml").exists());
|
||||
}
|
||||
|
||||
/// Credential vanished mid-fetch → apply Skipped, no marker (sibling of contention skip).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn credential_gone_mid_fetch_writes_no_marker() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"deployment_id": "dep-1",
|
||||
"managed_config": TEAM_MANAGED,
|
||||
"requirements": TEAM_REQUIREMENTS,
|
||||
})
|
||||
.to_string();
|
||||
// Delay the response so we can clear the deployment key after the fetch starts
|
||||
// but before apply runs.
|
||||
let (url, count, auths) = spawn_mock_delayed(body, std::time::Duration::from_millis(200));
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!(
|
||||
"[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-GOING-AWAY\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let home_for_clear = home.clone();
|
||||
let clearer = std::thread::spawn(move || {
|
||||
// Wait until the mock has accepted a request, then drop the key.
|
||||
for _ in 0..50 {
|
||||
if *count.lock().unwrap() > 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
std::fs::write(
|
||||
home_for_clear.join("config.toml"),
|
||||
format!("[endpoints]\nmanaged_config_url = \"{url}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let synced = xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("sync should succeed (skip, not error)");
|
||||
clearer.join().unwrap();
|
||||
|
||||
assert!(!synced, "credential-gone apply must not report a write");
|
||||
assert!(
|
||||
!auths.lock().unwrap().is_empty(),
|
||||
"the fetch must have reached the server"
|
||||
);
|
||||
assert!(
|
||||
!home
|
||||
.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"credential-gone must not write a marker for an unapplied body"
|
||||
);
|
||||
assert!(!home.join("requirements.toml").exists());
|
||||
}
|
||||
|
||||
/// A dk-synced marker means the KEY owns this machine's policy: with the key line gone
|
||||
/// from config.toml (the shape of a transient read failure) and a team user signed in,
|
||||
/// the gate must NOT purge. Pins the marker-scoped exemption — one keyed on live config
|
||||
/// resolution would purge here.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn dk_synced_marker_survives_config_blip_with_team_signed_in() {
|
||||
let home = test_home().clone();
|
||||
reset(&home);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"deployment_id": "deploy-A",
|
||||
"managed_config": TEAM_MANAGED,
|
||||
"requirements": format!("fail_closed = true\n{TEAM_REQUIREMENTS}"),
|
||||
})
|
||||
.to_string();
|
||||
let (url, _auths) = spawn_mock(body);
|
||||
std::fs::write(
|
||||
home.join("config.toml"),
|
||||
format!("[endpoints]\nmanaged_config_url = \"{url}\"\ndeployment_key = \"KEY-AAA\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
xai_grok_shell::managed_config::sync()
|
||||
.await
|
||||
.expect("deploy-key sync should succeed");
|
||||
assert!(home.join("requirements.toml").exists());
|
||||
|
||||
// The blip: the key line is gone (same shape as a transient config read failure),
|
||||
// while a team user is also signed in. Identity resolves Team("team-b"), which
|
||||
// differs from the marker principal ("deploy-A") — but the marker is key-scoped.
|
||||
write_config(&home, &url);
|
||||
write_team_auth(&home, "team-b");
|
||||
assert_eq!(
|
||||
xai_grok_config::confirmed_team_switch("team-b"),
|
||||
None,
|
||||
"a key-scoped marker must never confirm a team switch"
|
||||
);
|
||||
assert!(
|
||||
xai_grok_shell::managed_config::managed_policy_gate().is_ok(),
|
||||
"the blip must not refuse: the key-scoped marker still matches the on-disk policy"
|
||||
);
|
||||
assert!(
|
||||
home.join("requirements.toml").exists(),
|
||||
"the machine's enforced policy must survive the blip"
|
||||
);
|
||||
assert!(
|
||||
home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE)
|
||||
.exists(),
|
||||
"the dk marker must survive the blip"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1448,7 +1448,7 @@ fn process_dead_within(pid: u32, deadline: Duration) -> bool {
|
|||
#[cfg(unix)]
|
||||
fn read_task_pid(pid_file: &std::path::Path) -> u32 {
|
||||
let start = std::time::Instant::now();
|
||||
while !pid_file.exists() && start.elapsed() < Duration::from_secs(2) {
|
||||
while !pid_file.exists() && start.elapsed() < Duration::from_secs(10) {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let contents = std::fs::read_to_string(pid_file).unwrap_or_else(|e| {
|
||||
|
|
@ -1468,7 +1468,10 @@ fn read_task_pid(pid_file: &std::path::Path) -> u32 {
|
|||
/// answer for the post-tool turn.
|
||||
#[cfg(unix)]
|
||||
fn enqueue_background_task_turn(server: &MockInferenceServer, pid_file: &std::path::Path) {
|
||||
let command = format!("echo $$ > {} && exec /bin/sleep 300", pid_file.display());
|
||||
let command = format!(
|
||||
"echo $$ > {0} && /bin/sync {0} 2>/dev/null; exec /bin/sleep 300",
|
||||
pid_file.display()
|
||||
);
|
||||
let args = serde_json::json!({
|
||||
"command": command,
|
||||
"description": "start long-lived background process",
|
||||
|
|
|
|||
Loading…
Reference in a new issue