Synced from monorepo

Changes:
- Classify clipboard delivery confidence
- Add durable session update append
- Scope the xAI session bearer to first-party memory embedding endpoints
- Persist subagent outputs to disk and bound long-lived agent state
- Add MiniSweAgent:bash for mini-swe-agent parity
- Revert taking local sessions off the persistent shell
- Contextual tip recommending grok wrap on SSH sessions
- Voice STT bearer from model BYOK env_key/api_key
- Define exact website policies for sandbox
- Gate unsafe shell environments
- Shared pin hoist; single require_sha gate for marketplace plugins
- Server-signed is-managed claim (closes sidecar-removal downgrade)
- Optional require_sha pin for remote plugin installs
- Show session title and last exchange in the exit resume hint
- Gate shell output redirects
- Warn when fail_closed is present but not a boolean
- Add canonical text editing core (ratatui-textarea)
- Keep execution state out of goal scratch
- Add acknowledged persistence primitives
- Inherit child network restrictions in sandbox
- Fail closed when hook matchers fail to recompile
- Add MCP setup preferences for plugin MCPs
- Gate sourced shell scripts
- Gate file-typed project hooks
- grok wrap: restore terminal modes on child death
- Harden owner-only permissions on auth and MCP credentials
- Create crash dump files with owner-only permissions
- Write the agent_id cache owner-only (0600)
- SessionMetrics mode skips Mixpanel profile sync
- Dashboard: slim live-tail peek
- Yank full queued prompt text, not (+N lines)
- Defeat clock-rollback on the signed managed-config cache
- Stop early session/cancel from overtaking the prompt and wedging the turn slot
- Self-heal a diverged agent entrypoint on startup
- Add matched inference expectations in test-support
- Add AuthSingleFlight cancel/successor gap tests
- Remove consumer from external OTEL allowlist and pin scrub coverage
- Enable /copy in minimal mode
- Surface capacity and API-key detail on 429 errors
- Single-flight interactive auth
- Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -108,6 +108,7 @@ pub struct ResolvedContextualHints {
pub send_now: bool,
pub small_screen: bool,
pub word_select: bool,
pub ssh_wrap: bool,
}
impl Default for ResolvedContextualHints {
@ -119,6 +120,7 @@ impl Default for ResolvedContextualHints {
send_now: true,
small_screen: true,
word_select: true,
ssh_wrap: true,
}
}
}
@ -148,6 +150,7 @@ pub fn resolve_contextual_hints(
send_now: resolve_tip(ui.send_now, remote.and_then(|r| r.send_now)),
small_screen: resolve_tip(ui.small_screen, remote.and_then(|r| r.small_screen)),
word_select: resolve_tip(ui.word_select, remote.and_then(|r| r.word_select)),
ssh_wrap: resolve_tip(ui.ssh_wrap, remote.and_then(|r| r.ssh_wrap)),
}
}
@ -255,6 +258,7 @@ mod tests {
send_now,
small_screen: None,
word_select,
ssh_wrap: None,
}
}
@ -268,6 +272,7 @@ mod tests {
assert!(resolved.send_now, "send_now defaults ON");
assert!(resolved.small_screen, "small_screen defaults ON");
assert!(resolved.word_select, "word_select defaults ON");
assert!(resolved.ssh_wrap, "ssh_wrap defaults ON");
}
#[test]
@ -285,19 +290,26 @@ mod tests {
assert!(resolved.send_now);
assert!(resolved.small_screen);
assert!(resolved.word_select);
assert!(resolved.ssh_wrap);
}
#[test]
fn contextual_hints_remote_tier_controls_default_per_tip() {
let _g = contextual_hints_guard();
// Remote disables plan_mode; absent tips fall through to default ON.
let r = remote(None, Some(false), None, None, None);
// Remote disables plan_mode + ssh_wrap; absent tips fall through to
// default ON. Setting two distinct fields also catches a cross-wired
// resolver line (reading one remote field into another's gate).
let r = ContextualHintsRemote {
ssh_wrap: Some(false),
..remote(None, Some(false), None, None, None)
};
let resolved = resolve_contextual_hints(&ContextualHints::default(), Some(&r));
assert!(resolved.undo, "absent remote tip → default ON");
assert!(!resolved.plan_mode, "remote `false` soft-disables");
assert!(resolved.image_input);
assert!(resolved.send_now);
assert!(resolved.word_select);
assert!(!resolved.ssh_wrap, "remote `false` soft-disables ssh_wrap");
}
#[test]
@ -328,6 +340,7 @@ mod tests {
send_now: Some(false),
small_screen: Some(false),
word_select: Some(false),
ssh_wrap: Some(false),
};
let r = remote(
Some(false),
@ -344,6 +357,7 @@ mod tests {
&& resolved.send_now
&& resolved.small_screen
&& resolved.word_select
&& resolved.ssh_wrap
);
unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) };
}
@ -360,6 +374,7 @@ mod tests {
send_now: Some(true),
small_screen: Some(true),
word_select: Some(true),
ssh_wrap: Some(true),
};
let r = remote(Some(true), Some(true), Some(true), Some(true), Some(true));
let resolved = resolve_contextual_hints(&ui, Some(&r));
@ -370,6 +385,7 @@ mod tests {
&& !resolved.send_now
&& !resolved.small_screen
&& !resolved.word_select
&& !resolved.ssh_wrap
);
unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) };
}

View file

@ -12,7 +12,11 @@ use xai_grok_tools::types::compat::{CompatConfig, CompatConfigToml};
pub use xai_grok_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap};
// MCP server config value types extracted to `xai-grok-config-types` (config
// dependency inversion); re-exported so `crate::util::config::*` paths keep working.
pub use xai_grok_config_types::{McpJsonOAuthBlock, McpServerConfig, McpServerTransportConfig};
pub use xai_grok_config_types::{
McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, McpServerConfig,
McpServerPreferences, McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue,
McpSetupField, McpSetupFieldType, McpSetupOption, McpSetupResolution,
};
// Permission-policy value types likewise extracted; re-exported to keep paths stable.
pub use xai_grok_config_types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
@ -141,8 +145,17 @@ pub fn load_mcp_servers_with_oauth(
let mut oauth_configs = McpOAuthConfigMap::new();
let mut acp_servers = Vec::new();
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
for (name, mut config) in servers_map {
for (name, config) in servers_map {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => continue,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
continue;
}
};
config.expand_strings(sub);
if let Some(oauth) = config.oauth_config() {
oauth_configs.insert(name.clone(), oauth);
@ -195,10 +208,19 @@ pub fn load_mcp_servers(cwd: &std::path::Path, compat: &CompatConfig) -> Vec<acp
/// tracking. Using [`load_mcp_servers`] there would cause all entries to be
/// tagged as `ConfigSource::ConfigToml`, hiding the true origin.
pub(crate) fn load_mcp_servers_toml_only(cwd: &std::path::Path) -> Vec<acp::McpServer> {
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
load_all_mcp_configs(cwd)
.into_iter()
.filter_map(|(name, mut config)| {
.filter_map(|(name, config)| {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => return None,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
return None;
}
};
config.expand_strings(sub);
config.to_acp_mcp_server(name)
})
@ -267,10 +289,19 @@ pub(crate) fn reload_mcp_servers_merged(
servers.entry(name).or_insert(config);
}
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
servers
.into_iter()
.filter_map(|(name, mut config)| {
.filter_map(|(name, config)| {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => return None,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
return None;
}
};
config.expand_strings(sub);
config.to_acp_mcp_server(name)
})
@ -317,6 +348,230 @@ pub fn all_toml_mcp_server_names(cwd: &std::path::Path) -> std::collections::Has
load_all_mcp_configs(cwd).keys().cloned().collect()
}
pub fn mcp_preferences_path() -> PathBuf {
xai_grok_config::grok_home().join("mcp_preferences.json")
}
/// Result of loading prefs. Corrupt files are readable as empty for resolution
/// but must not be overwritten (would clobber other servers).
#[derive(Debug, Clone)]
pub enum McpPreferencesLoad {
Ok(McpPreferencesFile),
Missing,
Corrupt,
}
impl McpPreferencesLoad {
pub fn file(&self) -> McpPreferencesFile {
match self {
Self::Ok(f) => f.clone(),
Self::Missing | Self::Corrupt => McpPreferencesFile::default(),
}
}
pub fn is_writable(&self) -> bool {
!matches!(self, Self::Corrupt)
}
}
pub fn load_mcp_preferences() -> McpPreferencesLoad {
load_mcp_preferences_from(&mcp_preferences_path())
}
pub fn load_mcp_preferences_from(path: &std::path::Path) -> McpPreferencesLoad {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return McpPreferencesLoad::Missing,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to read MCP preferences");
return McpPreferencesLoad::Corrupt;
}
};
match serde_json::from_str(&content) {
Ok(file) => McpPreferencesLoad::Ok(file),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to parse MCP preferences");
McpPreferencesLoad::Corrupt
}
}
}
pub async fn save_mcp_preferences(prefs: &McpPreferencesFile) -> Result<()> {
save_mcp_preferences_to(&mcp_preferences_path(), prefs).await
}
pub async fn save_mcp_preferences_to(
path: &std::path::Path,
prefs: &McpPreferencesFile,
) -> Result<()> {
if matches!(load_mcp_preferences_from(path), McpPreferencesLoad::Corrupt) {
anyhow::bail!(
"refusing to overwrite unreadable MCP preferences at {}",
path.display()
);
}
let json = serde_json::to_string_pretty(prefs)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = path.with_extension(format!(
"json.tmp.{}{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
tokio::fs::write(&tmp, &json).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|e| anyhow::anyhow!("failed to set mcp preferences permissions: {e}"))?;
}
tokio::fs::rename(&tmp, path).await?;
Ok(())
}
/// Restore a single server key after a failed setup (best-effort).
pub async fn restore_mcp_preference_server(
server_name: &str,
previous: Option<McpServerPreferences>,
) -> Result<()> {
let load = load_mcp_preferences();
if !load.is_writable() {
return Ok(());
}
let mut prefs = load.file();
match previous {
Some(entry) => {
prefs.servers.insert(server_name.to_string(), entry);
}
None => {
prefs.servers.remove(server_name);
}
}
save_mcp_preferences(&prefs).await
}
/// Unresolved setup-bearing MCP config collected for `/mcps` list and auth.
#[derive(Debug, Clone)]
pub struct McpSetupServerEntry {
pub name: String,
pub config: McpServerConfig,
pub source: McpPreferenceSource,
}
/// Collect MCP configs that declare a `setup` schema from config and plugins.
/// Used to surface setup-required rows and drive `x.ai/mcp/setup`.
pub fn collect_mcp_setup_configs(
cwd: &std::path::Path,
plugin_registry: Option<&xai_grok_agent::plugins::PluginRegistry>,
compat: &CompatConfig,
) -> IndexMap<String, McpSetupServerEntry> {
let mut result = IndexMap::new();
for (name, (config, scope)) in load_mcp_server_configs_with_project(cwd) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.insert(
name.clone(),
McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(scope.to_string()),
},
},
);
}
if !crate::claude_import::is_claude_import_marked_with_log("collect_mcp_setup_configs") {
for (name, config) in load_claude_json_mcp_servers_as_configs(cwd, compat) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_USER.to_string()),
},
});
}
for (name, config) in load_cursor_mcp_servers_as_configs(cwd, compat) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_USER.to_string()),
},
});
}
for (name, config) in load_mcp_json_servers_as_configs(cwd) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_PROJECT.to_string()),
},
});
}
}
if let Some(registry) = plugin_registry {
let toml_claimed_names = all_toml_mcp_server_names(cwd);
for plugin in registry.active_plugins() {
// File first, then inline; first-wins matches runtime plugin load.
let mut plugin_configs = IndexMap::new();
if let Some(ref mcp_path) = plugin.mcp_config_path
&& let Some(config) = read_mcp_json(mcp_path)
{
for (name, server) in config.mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
}
if let Some(ref inline_value) = plugin.inline_mcp_servers {
let normalized =
xai_grok_agent::plugins::manifest::normalize_inline_mcp_servers(inline_value);
if let Ok(config) = serde_json::from_value::<McpConfig>(normalized) {
for (name, server) in config.mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
}
}
for (name, config) in plugin_configs {
if toml_claimed_names.contains(&name) || !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "plugin".to_string(),
plugin: Some(plugin.name.clone()),
scope: None,
},
});
}
}
}
result
}
pub const MANAGED_GATEWAY_DISABLED_CONNECTORS_KEY: &str = "__managed_gateway_connectors";
/// Persist `disabled_tools` for a server under `[disabled_mcp_tools]` in config.toml.
@ -681,10 +936,23 @@ pub(crate) fn parse_mcp_config_with_oauth(
source_label: &str,
sub: &dyn Fn(&str) -> String,
) -> (Vec<acp::McpServer>, McpOAuthConfigMap) {
let preferences = load_mcp_preferences().file();
let mut servers = Vec::new();
let mut oauth_configs = McpOAuthConfigMap::new();
for (name, server_config) in &config.mcp_servers {
let mut server_config = server_config.clone();
let mut server_config = match server_config.resolve_setup(preferences.servers.get(name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => continue,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(
source = source_label,
server = %name,
error = %reason,
"MCP setup config is invalid"
);
continue;
}
};
server_config.expand_strings(sub);
if let Some(oauth) = server_config.oauth_config() {
oauth_configs.insert(name.clone(), oauth);
@ -1651,5 +1919,52 @@ enabled = false
);
}
#[tokio::test]
async fn mcp_preferences_missing_malformed_and_save_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_preferences.json");
assert!(matches!(
load_mcp_preferences_from(&path),
McpPreferencesLoad::Missing
));
assert!(load_mcp_preferences_from(&path).file().servers.is_empty());
std::fs::write(&path, "not json").unwrap();
assert!(matches!(
load_mcp_preferences_from(&path),
McpPreferencesLoad::Corrupt
));
let prefs = McpPreferencesFile {
version: 1,
servers: HashMap::from([(
"acme".to_string(),
McpServerPreferences {
values: HashMap::from([("site".to_string(), "us5".to_string())]),
source: Some(McpPreferenceSource {
kind: "plugin".to_string(),
plugin: Some("acme".to_string()),
scope: None,
}),
updated_at: Some("2026-06-19T00:00:00Z".to_string()),
},
)]),
};
assert!(save_mcp_preferences_to(&path, &prefs).await.is_err());
std::fs::remove_file(&path).unwrap();
save_mcp_preferences_to(&path, &prefs).await.unwrap();
let loaded = load_mcp_preferences_from(&path).file();
assert_eq!(loaded.servers["acme"].values["site"], "us5");
assert_eq!(
loaded.servers["acme"]
.source
.as_ref()
.unwrap()
.plugin
.as_deref(),
Some("acme")
);
}
// === merge_section tests ===
}

View file

@ -62,155 +62,6 @@ fn resolve_search_tool_enabled(
env.or(config).or(managed).unwrap_or(true)
}
const ENV_PERSISTENT_SHELL: &str = "GROK_PERSISTENT_SHELL";
fn persistent_shell_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("toolset")?
.get("bash")?
.get("persistent_shell")?
.as_bool()
}
pub fn resolve_persistent_local_shell(remote: Option<bool>) -> bool {
let requirements = crate::config::load_merged_requirements();
let layers = match crate::config::ConfigLayers::load() {
Ok(l) => Some(l),
Err(e) => {
tracing::warn!(error = %e, "persistent_local_shell: failed to load config layers");
None
}
};
resolve_persistent_local_shell_tiers(
requirements.as_ref(),
layers.as_ref().map(|l| &l.user),
layers.as_ref().map(|l| &l.managed),
layers.as_ref().map(|l| &l.system_managed),
remote,
)
}
fn resolve_persistent_local_shell_tiers(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
system_managed: Option<&TomlValue>,
remote: Option<bool>,
) -> bool {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_PERSISTENT_SHELL)
.requirement(persistent_shell_from_toml(requirements))
.config(persistent_shell_from_toml(user))
.managed(
persistent_shell_from_toml(managed)
.or_else(|| persistent_shell_from_toml(system_managed)),
)
.feature_flag(remote)
.default(true)
.resolve()
.value
}
#[cfg(test)]
mod persistent_local_shell_tests {
use super::{ENV_PERSISTENT_SHELL, resolve_persistent_local_shell_tiers};
use toml::Value as TomlValue;
// GROK_PERSISTENT_SHELL is process-global (the documented kill-switch a dev
// may export); serialize and force it unset so these tests can't go flaky.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_PERSISTENT_SHELL) };
g
}
fn cfg(persistent: bool) -> TomlValue {
toml::from_str(&format!(
"[toolset.bash]\npersistent_shell = {persistent}\n"
))
.unwrap()
}
#[test]
fn default_is_true() {
let _g = guard();
assert!(resolve_persistent_local_shell_tiers(
None, None, None, None, None
));
}
#[test]
fn remote_false_rolls_back() {
let _g = guard();
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
None,
None,
Some(false)
));
}
#[test]
fn config_false_rolls_back() {
let _g = guard();
let off = cfg(false);
assert!(!resolve_persistent_local_shell_tiers(
None,
Some(&off),
None,
None,
None
));
}
#[test]
fn config_beats_remote() {
let _g = guard();
let on = cfg(true);
assert!(resolve_persistent_local_shell_tiers(
None,
Some(&on),
None,
None,
Some(false)
));
}
#[test]
fn requirement_overrides_remote() {
let _g = guard();
let on = cfg(true);
assert!(resolve_persistent_local_shell_tiers(
Some(&on),
None,
None,
None,
Some(false)
));
}
#[test]
fn managed_and_system_managed_apply_below_config() {
let _g = guard();
let off = cfg(false);
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
Some(&off),
None,
None
));
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
None,
Some(&off),
None
));
}
}
/// Env override for `[toolset.ask_user_question] timeout_enabled` (parsed by
/// the shared [`xai_grok_config::env_bool`] via `BoolFlag`). The secs env var
/// lives in the tools crate (`RESPONSE_TIMEOUT_ENV`), parsed once there.

View file

@ -60,6 +60,11 @@ pub async fn set_contextual_hint_word_select(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.word_select = Some(value)).await
}
/// Persist `[ui.contextual_hints].ssh_wrap` via `update_config`.
pub async fn set_contextual_hint_ssh_wrap(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.ssh_wrap = Some(value)).await
}
/// Persist `[ui].theme` via `update_config`. Caller must pass the
/// canonical theme name (`groknight`, `tokyonight`, `auto`, etc.).
pub async fn set_theme(value: String) -> Result<()> {