Synced from monorepo

Synced from monorepo

Changes:
- Report invalid MCP server config instead of failing startup
- Keep completed terminal output when the gateway connection is lost
- Show a duration-only detail view for single-task task output
- Don't let a stale registry turn counter hide local sessions
- Raise the file-descriptor soft limit on Linux and log effective limits at startup
- Stop aborting when HTTP client construction fails
- Make session thread and runtime spawn failures recoverable
- Fix main-prompt paste parity in the question freeform input
- Fire SessionEnd hooks on /exit and headless quit
- Embed the deployment-config signing public key
- Repaint paste-chip background on inline panel inputs
- Security: prevent acceptEdits from auto-approving agent writes into the always-trusted global hook root
- Fix stacked "Worked for" markers so parks render as status and turns close with exactly one marker
- Parse hooks from config files
- Add a remote kill-switch for managed-config signature verification
- Security: fix workspace file-reference resolution bypassing workspace filesystem confinement

Source-Revision: d02693a856a54f1030695b36b91d276e96b30b23
This commit is contained in:
grokkybara[bot] 2026-07-25 18:44:42 +00:00
commit 47348d13ec
138 changed files with 7283 additions and 5796 deletions

View file

@ -35,6 +35,17 @@ use crate::session::{SessionCommand, SessionHandle};
/// not yet exited.
const FLUSH_POLL: Duration = Duration::from_millis(50);
/// Default bound on a process-exit session flush ([`AgentActivity::flush_all_sessions`]):
/// leader auto-update shutdown and the in-process agent's `/exit` / headless-quit
/// path both use it, so one wedged actor delays exit by the same amount everywhere.
/// Sessions are normally idle by then and the flush completes in milliseconds.
///
/// Known gap: a `SessionEnd` hook configured with a longer `timeout` than this
/// is still cut off at the grace. Aligning the two needs the hook registry's
/// configured timeouts at flush time, which this layer does not see — tracked as
/// a follow-up rather than hardcoding a larger bound for every exit.
pub const SESSION_FLUSH_GRACE: Duration = Duration::from_secs(10);
/// Per-session slice of state shared with the session actor (the same `Arc`s
/// the actor mutates — see the matching `SessionHandle` fields).
struct SessionActivityEntry {
@ -131,9 +142,13 @@ impl AgentActivity {
/// with a fresh actor gets its own signal), all against one deadline —
/// `grace` bounds the **total** shutdown delay.
///
/// Call **before** cancelling the leader's root token so session state
/// is durable before the `LocalSet` drop aborts remaining tasks. Actors
/// that miss the grace are logged and abandoned.
/// Callers: the leader's auto-update / `RelaunchForUpdate` shutdown, and
/// the in-process agent worker on `/exit` / headless quit. In the leader
/// case, call **before** cancelling the root token; in the in-process case,
/// **after** the cancel that ends the worker's run loop but before its
/// `LocalSet` drops — either way, session state must be durable before the
/// drop aborts remaining tasks. Actors that miss the grace are logged and
/// abandoned.
pub async fn flush_all_sessions(&self, grace: Duration) {
let deadline = tokio::time::Instant::now() + grace;
// Every distinct channel signaled so far (id kept for logging).
@ -148,7 +163,7 @@ impl AgentActivity {
.collect();
for (id, tx) in snapshot {
if !signaled.iter().any(|(_, s)| s.same_channel(&tx)) {
tracing::info!(session_id = %id, "leader shutdown: flushing session");
tracing::info!(session_id = %id, "shutdown: flushing session");
let _ = tx.send(SessionCommand::Shutdown);
signaled.push((id, tx));
}
@ -162,7 +177,7 @@ impl AgentActivity {
if !tx.is_closed() {
tracing::warn!(
session_id = %id,
"leader shutdown: session actor did not exit within grace; proceeding"
"shutdown: session actor did not exit within grace; proceeding"
);
}
}

View file

@ -60,9 +60,10 @@ pub struct LeaderAutoUpdateConfig {
const AUTO_UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// How long the auto-update shutdown waits for session actors to flush
/// before the leader exits. Sessions are idle at this point, so the flush
/// normally completes in milliseconds; the cap only bounds a wedged actor.
const AUTO_UPDATE_FLUSH_GRACE: Duration = Duration::from_secs(10);
/// before the leader exits. Aliases the shared
/// [`crate::agent::activity::SESSION_FLUSH_GRACE`] so this path and the
/// in-process agent's `/exit` / headless-quit flush cannot drift apart.
const AUTO_UPDATE_FLUSH_GRACE: Duration = crate::agent::activity::SESSION_FLUSH_GRACE;
/// Consecutive busy deferrals after which an installed update proceeds
/// anyway (with the graceful flush). Bounds how long a permanently-"busy"
@ -829,8 +830,8 @@ fn relay_config_for_session(
}
/// Start the leader's grok.com relay connection according to the start policy,
/// returning the slot where the [`RelayHandle`](crate::agent::relay::RelayHandle)
/// is parked once the connection task is running.
/// parking the [`RelayHandle`](crate::agent::relay::RelayHandle) in `slot`
/// once the connection task is running.
///
/// * `relay_on_demand == false` (default — explicit `grok agent leader`
/// invocation: devbox / systemd / nohup): connect **eagerly**, right now.
@ -854,29 +855,29 @@ fn relay_config_for_session(
/// via `session/load`).
///
/// Must be called within a `LocalSet` (uses `spawn_local`). The handle is
/// parked in a slot rather than returned from the deferred task because
/// `RelayHandle` cancels its loop on Drop; the leader shutdown path takes it
/// out of the slot to stop the relay explicitly (the `cancel` token would stop
/// it anyway).
/// parked in the caller-owned `slot` rather than returned from the deferred
/// task because `RelayHandle` cancels its loop on Drop; the leader shutdown
/// path takes it out of the slot to stop the relay explicitly (the `cancel`
/// token would stop it anyway). The slot is passed in (not created here) so
/// a deferred arm ([`DeferredRelayArm`]) parks the handle in the same slot
/// the shutdown path drains.
fn spawn_leader_relay(
slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>>,
relay_config: crate::agent::relay::RelayConfig,
relay_on_demand: bool,
mut relay_demand_rx: tokio::sync::watch::Receiver<bool>,
ws_to_agent_tx: mpsc::UnboundedSender<String>,
agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>>,
cancel: tokio_util::sync::CancellationToken,
) -> Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>> {
) {
use crate::agent::relay::spawn_relay_connection;
let slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>> =
Rc::new(std::cell::RefCell::new(None));
if !relay_on_demand {
info!("Starting relay connection (eager)");
let (tx, handle) = spawn_relay_connection(relay_config, ws_to_agent_tx, cancel);
*agent_to_ws_tx.lock() = Some(tx);
*slot.borrow_mut() = Some(handle);
return slot;
return;
}
let slot_for_task = slot.clone();
@ -903,14 +904,75 @@ fn spawn_leader_relay(
*agent_to_ws_tx.lock() = Some(tx);
*slot_for_task.borrow_mut() = Some(handle);
});
slot
}
/// Everything needed to arm the leader's grok.com relay *after* startup.
///
/// A leader that boots without auth used to disable the relay forever — the
/// decision was made once in [`run_leader`] and never revisited. On devboxes
/// that turned a transient mint-provider outage at provision time into a
/// permanently invisible box: the external auth provider succeeded minutes
/// later and the config watcher hot-reloaded the token into the leader, but
/// the relay never connected, the agent never registered, and tooling
/// reported the (healthy) box as "not found online" for its whole lifetime.
///
/// These parts are captured in the no-auth startup path and consumed by the
/// config-update loop on the first relay-eligible
/// [`ConfigUpdate::Auth`](crate::config::reloader::ConfigUpdate::Auth).
struct DeferredRelayArm {
relay_on_demand: bool,
relay_demand_rx: tokio::sync::watch::Receiver<bool>,
ws_to_agent_tx: mpsc::UnboundedSender<String>,
agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>>,
cancel: tokio_util::sync::CancellationToken,
/// Shared with [`run_leader`]'s shutdown path, which drains it to stop
/// the relay explicitly.
slot: Rc<std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>>,
grok_com_config: crate::auth::GrokComConfig,
alpha_test_key: Option<String>,
}
impl DeferredRelayArm {
/// Arm the relay for a hot-reloaded session if it is relay-eligible.
///
/// Consumes the parts and returns `None` when the relay was armed.
/// Returns `Some(self)` when the session is not relay-eligible (BYOK /
/// non-x.ai issuer — see
/// [`RelayConfig::for_session`](crate::agent::relay::RelayConfig::for_session))
/// so a later eligible token can still arm.
///
/// Must be called within a `LocalSet` (delegates to
/// [`spawn_leader_relay`]).
fn arm_if_eligible(self, session: &GrokAuth, auth_manager: &Arc<AuthManager>) -> Option<Self> {
let Some(relay_config) = crate::agent::relay::RelayConfig::for_session(
session,
&self.grok_com_config,
self.alpha_test_key.clone(),
Some(auth_manager.clone()),
) else {
return Some(self);
};
info!("Relay-eligible auth token appeared after startup — arming grok.com relay");
spawn_leader_relay(
self.slot,
relay_config,
self.relay_on_demand,
self.relay_demand_rx,
self.ws_to_agent_tx,
self.agent_to_ws_tx,
self.cancel,
);
None
}
}
/// Run the agent in leader mode, accepting IPC connections from multiple clients.
/// When a grok.com session is present, the leader connects to the websocket relay
/// after startup (post-auth, post-prefetch); BYOK / no-session leaders skip it and
/// serve clients over IPC only. See [`spawn_leader_relay`] for when the relay
/// connection is opened (eager by default, demand-gated with `relay_on_demand`).
/// after startup (post-auth, post-prefetch); BYOK / no-session leaders start
/// serving clients over IPC only, then arm the relay if a relay-eligible token
/// is hot-reloaded later (see [`DeferredRelayArm`]). See [`spawn_leader_relay`]
/// for when the relay connection is opened (eager by default, demand-gated with
/// `relay_on_demand`).
///
/// Startup sequence (lock-then-socket):
/// 1. Acquire the leader flock FIRST — bail if another process holds it.
@ -1198,7 +1260,10 @@ pub async fn run_leader(
// process so a refresh can't straddle a suspend.
shared_auth_manager.start_system_power_listener();
// Decided once here; not (re)started if a client authenticates mid-session.
// Resolved from startup auth here; when this is `None` (leader booted
// without auth) the relay is NOT permanently off — the config-update loop
// arms it later via `DeferredRelayArm` when the watcher hot-reloads a
// relay-eligible token.
// The refresher lands on `shared_auth_manager` during `MvpAgent`
// construction below; a relay 401 in the window before that surfaces as
// a transient recovery failure and is retried, not a dead end.
@ -1363,19 +1428,42 @@ pub async fn run_leader(
// connect unconditionally. Leaders auto-spawned by interactive
// clients pass `relay_on_demand` and defer the WebSocket until the
// first headless registration. See `spawn_leader_relay`.
let relay_handle_slot = if let Some(relay_config) = relay_config {
let relay_handle_slot: Rc<
std::cell::RefCell<Option<crate::agent::relay::RelayHandle>>,
> = Rc::new(std::cell::RefCell::new(None));
let mut deferred_relay_arm: Option<DeferredRelayArm> = None;
if let Some(relay_config) = relay_config {
spawn_leader_relay(
relay_handle_slot.clone(),
relay_config,
relay_on_demand,
relay_demand_rx,
ws_to_agent_tx.clone(),
agent_to_ws_tx.clone(),
cancel_clone.clone(),
)
);
} else {
info!("Relay disabled: no grok.com session token (BYOK / local-only leader)");
Rc::new(std::cell::RefCell::new(None))
};
// No relay-eligible auth at startup (BYOK / local-only — or a
// devbox whose initial mint failed transiently). Don't decide
// "relay off" forever: park the parts so the config-update
// loop below arms the relay when the watcher hot-reloads a
// relay-eligible token. See `DeferredRelayArm`.
info!(
"Relay not started: no grok.com session token \
(BYOK / local-only leader); will arm if an eligible \
token is hot-reloaded"
);
deferred_relay_arm = Some(DeferredRelayArm {
relay_on_demand,
relay_demand_rx,
ws_to_agent_tx: ws_to_agent_tx.clone(),
agent_to_ws_tx: agent_to_ws_tx.clone(),
cancel: cancel_clone.clone(),
slot: relay_handle_slot.clone(),
grok_com_config: agent_config.grok_com_config.clone(),
alpha_test_key: agent_config.endpoints.alpha_test_key.clone(),
});
}
// Spawn auto-update checker if configured.
let update_cancel = cancel_clone.clone();
@ -1502,7 +1590,24 @@ pub async fn run_leader(
"expires_at": auth.expires_at.map(|e| e.to_rfc3339()),
})),
);
// Cloned only while a deferred relay arm is
// pending (leader booted without auth) — `None`
// for the lifetime of a normally-authed leader.
let session_for_relay = deferred_relay_arm
.is_some()
.then(|| (*auth).clone());
auth_manager_for_config.hot_swap(*auth);
// Deferred relay arm for a leader that booted
// without auth (post-hot-swap, so the shared
// manager already holds the token when the relay
// connects). A non-eligible token (BYOK) hands
// the parts back for a later attempt.
if let (Some(arm), Some(session)) =
(deferred_relay_arm.take(), session_for_relay)
{
deferred_relay_arm = arm
.arm_if_eligible(&session, &auth_manager_for_config);
}
models_manager_for_config.on_auth_changed().await;
let line = internal_reload_request_line(
"config-auth-reloaded",
@ -1846,7 +1951,9 @@ mod tests {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let slot = spawn_leader_relay(
let slot = Rc::new(std::cell::RefCell::new(None));
spawn_leader_relay(
slot.clone(),
config,
false, // eager: explicit `grok agent leader` invocation
demand_rx,
@ -1885,7 +1992,13 @@ mod tests {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let _slot = spawn_leader_relay(
// Keep an Rc on the slot for the whole test: the demand task
// drops its clone after parking the handle, and `RelayHandle`
// cancels the relay loop on Drop (mirrors `run_leader`, which
// owns the slot until shutdown).
let slot = Rc::new(std::cell::RefCell::new(None));
spawn_leader_relay(
slot.clone(),
config,
true, // on-demand: spawned via spawn_leader_subprocess
demand_rx,
@ -1910,6 +2023,81 @@ mod tests {
cancel.cancel();
}
/// Regression test for the "leader booted without auth is invisible
/// forever" bug: a leader that starts with no session (e.g. a devbox
/// whose initial mint hit a transient provider outage) must arm the
/// relay when a relay-eligible token is later hot-reloaded — and must
/// hand the parts back (not consume them) for a non-eligible token, so
/// a later eligible one can still arm.
#[tokio::test]
async fn deferred_arm_connects_relay_when_auth_appears() {
let (addr, count) = spawn_mock_relay_server().await;
let cancel = CancellationToken::new();
let (ws_to_agent_tx, _ws_to_agent_rx) = mpsc::unbounded_channel();
let agent_to_ws_tx: Rc<Mutex<Option<mpsc::UnboundedSender<String>>>> =
Rc::new(Mutex::new(None));
let (_demand_tx, demand_rx) = watch::channel(false);
let slot = Rc::new(std::cell::RefCell::new(None));
let grok_com_config = crate::auth::GrokComConfig {
grok_ws_url: format!("ws://{addr}"),
grok_ws_origin: format!("http://{addr}"),
..Default::default()
};
let tmp = tempfile::tempdir().unwrap();
let auth_manager = Arc::new(AuthManager::new(tmp.path(), grok_com_config.clone()));
let arm = DeferredRelayArm {
relay_on_demand: false, // bare leader: eager once armed
relay_demand_rx: demand_rx,
ws_to_agent_tx,
agent_to_ws_tx: agent_to_ws_tx.clone(),
cancel: cancel.clone(),
slot: slot.clone(),
grok_com_config,
alpha_test_key: None,
};
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// A non-relay-eligible token (no x.ai issuer) must not arm
// and must hand the parts back.
let ineligible = GrokAuth::test_default();
let arm = arm
.arm_if_eligible(&ineligible, &auth_manager)
.expect("non-eligible token must hand the parts back");
assert!(slot.borrow().is_none(), "no handle parked yet");
assert_eq!(
count.load(Ordering::SeqCst),
0,
"non-eligible token must not connect the relay"
);
// A relay-eligible x.ai OIDC token arms the relay eagerly.
let eligible = GrokAuth {
auth_mode: AuthMode::Oidc,
oidc_issuer: Some(crate::auth::XAI_OAUTH2_ISSUER.to_string()),
..GrokAuth::test_default()
};
assert!(
arm.arm_if_eligible(&eligible, &auth_manager).is_none(),
"eligible token must consume the arm parts"
);
assert!(
slot.borrow().is_some(),
"handle must be parked in the shared shutdown slot"
);
assert!(
agent_to_ws_tx.lock().is_some(),
"outbound relay sender must be installed"
);
wait_for_connection(&count, "deferred arm after auth hot-reload").await;
})
.await;
cancel.cancel();
}
/// The watcher-injected internal reload requests must carry the ACP
/// wire-level `_` extension prefix. `agent-client-protocol`'s inbound
/// decoder routes non-built-in methods to `ext_method` only when

View file

@ -2043,9 +2043,18 @@ impl Config {
t.remove("auth_provider");
t.remove("model_providers");
}
let parsed_mcp_servers =
crate::util::config::parse_mcp_servers_from_toml(&raw_without_model_sections);
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
t.remove("mcp_servers");
}
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
if let toml::Value::Table(ref mut t) = base {
t.remove("mcp_servers");
}
let (mut config, user_unused) =
Self::deserialize_collecting_unrecognized(base, &raw_without_model_sections)?;
config.mcp_servers = parsed_mcp_servers.into_iter().collect();
if !user_unused.is_empty() {
let keys = user_unused.join(", ");
tracing::warn!(
@ -3445,6 +3454,15 @@ pub fn apply_external_otel_remote_policy(settings: Option<&crate::util::config::
}
/// Seed free-function remote caches after writing `Config.remote_settings`.
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
if let Some(s) = settings {
let origin_trusted = crate::util::is_prod_cli_chat_proxy_url(
&EndpointsConfig::from_effective_config().proxy_url(),
);
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
s.managed_config_signature_verification,
origin_trusted,
);
}
crate::util::config::cache_remote_mcp_startup_timeout_secs(
settings.and_then(|s| s.mcp_startup_timeout_secs),
);
@ -5954,6 +5972,45 @@ reasoning_effort = "low"
.expect("warm cache resolves");
assert_eq!(resolved.api_key.as_deref(), Some("ws-token"));
}
/// GBT-4128: bad `[mcp_servers.*]` entries are dropped, not fatal.
#[test]
fn invalid_mcp_server_stub_does_not_fail_config_load() {
let raw_config: toml::Value = toml::from_str(
r#"
[mcp_servers.github]
enabled = false
mcp_servers.broken = "not-a-table"
[mcp_servers.also_broken]
enabled = "yes"
[mcp_servers.linear]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw_config)
.expect("bad mcp stubs must be dropped, not fail whole config");
assert!(
!cfg.mcp_servers.contains_key("broken"),
"non-table entry is dropped"
);
assert!(
!cfg.mcp_servers.contains_key("also_broken"),
"wrong-type enabled is dropped"
);
assert!(
!cfg.mcp_servers.contains_key("github"),
"transport-less stub is dropped (disable via disabled_mcp_servers)"
);
assert!(
cfg.mcp_servers.contains_key("linear"),
"valid MCP neighbor must still load"
);
assert!(cfg.mcp_servers["linear"].enabled);
}
/// The lenient parser warns per problem and never fails the whole
/// config.
#[test]
@ -12522,4 +12579,81 @@ default = "grok-4.5"
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
#[serial_test::serial(remote_sig_disarm)]
fn remote_settings_disarm_managed_config_signatures() {
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(false),
..Default::default()
};
apply_remote_settings_side_effects(Some(&settings));
assert!(!xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(true),
..Default::default()
};
apply_remote_settings_side_effects(Some(&settings));
assert!(xai_grok_config::signed_policy::verification_active());
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(false),
true,
);
apply_remote_settings_side_effects(None);
assert!(!xai_grok_config::signed_policy::verification_active());
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
}
/// Keyed path: prod proxy origin can disarm; env override cannot.
#[test]
#[serial_test::serial(remote_sig_disarm)]
fn remote_settings_disarm_requires_prod_proxy_when_keys_embedded() {
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
let settings = crate::util::config::RemoteSettings {
managed_config_signature_verification: Some(false),
..Default::default()
};
unsafe {
std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL");
}
apply_remote_settings_side_effects(Some(&settings));
assert!(
!xai_grok_config::signed_policy::verification_active(),
"prod proxy origin must allow disarm when keys are embedded"
);
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
assert!(xai_grok_config::signed_policy::verification_active());
unsafe {
std::env::set_var(
"GROK_CLI_CHAT_PROXY_BASE_URL",
"https://attacker.example/v1",
);
}
apply_remote_settings_side_effects(Some(&settings));
assert!(
xai_grok_config::signed_policy::verification_active(),
"env-overridden proxy must not be able to disarm keyed verification"
);
unsafe {
std::env::remove_var("GROK_CLI_CHAT_PROXY_BASE_URL");
}
xai_grok_config::signed_policy::apply_remote_managed_config_signature_verification(
Some(true),
true,
);
}
}

View file

@ -23,9 +23,12 @@ pub fn bootstrap(
auth_manager: &Arc<AuthManager>,
prefetched: Option<IndexMap<String, ModelEntry>>,
) -> Result<(AgentConfig, ModelsManager), String> {
// Fail closed before any policy is read: a tampered managed policy must not run unmanaged.
// Remote kill-switch before the gate (settings-only prefetch — no managed-config
// sync, so a live server cannot heal a tampered policy before fail-closed).
let mut cfg = cfg.clone();
ensure_remote_settings_side_effects(&mut cfg, false);
crate::managed_config::managed_policy_gate()?;
let cfg = resolve_config(cfg, auth_manager);
let cfg = resolve_config(&cfg, auth_manager);
cfg.validate_model_filters()?;
init_process(&cfg, auth_manager);
let models_manager = ModelsManager::from_config(&cfg, prefetched, auth_manager.clone())?;
@ -48,6 +51,42 @@ pub(crate) fn exit_on_config_error<T>(e: String) -> T {
std::process::exit(1);
}
/// Fill `remote_settings` if absent and apply process-global remote side effects
/// (signature kill-switch and caches). Safe to call more than once.
///
/// `sync_managed`: when true, missing-settings fallback may also refresh
/// managed-config. Must be false before the managed-policy gate.
fn ensure_remote_settings_side_effects(cfg: &mut AgentConfig, sync_managed: bool) {
// Fallback: if the client didn't pre-supply remote settings, fetch them
// now so remote-settings-gated features work regardless of which client
// spawned us. Clients that already call `start_early_prefetch()` and
// thread the result into `cfg.remote_settings` skip this entirely.
if cfg.remote_settings.is_none() {
let handle = if sync_managed {
crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone()))
} else {
crate::agent::models::start_early_prefetch_settings_only(Some(
cfg.grok_com_config.clone(),
))
};
if let Some(handle) = handle {
match handle.join() {
Ok(result) => {
cfg.remote_settings = result.settings;
crate::util::config::set_remote_campaigns_from_settings(
cfg.remote_settings.as_ref(),
);
tracing::info!("remote_settings fetched as shell-level fallback");
}
Err(_) => {
tracing::warn!("remote_settings fallback prefetch thread panicked");
}
}
}
}
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
}
/// Config transform: apply managed settings, fetch remote settings,
/// resolve storage mode.
fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig {
@ -74,29 +113,10 @@ fn resolve_config(cfg: &AgentConfig, auth_manager: &AuthManager) -> AgentConfig
tracing::info!(field = %e.path, value = %e.value, source = %e.source, "policy override");
}
// Fallback: if the client didn't pre-supply remote settings, fetch them
// now so remote-settings-gated features work regardless of which client
// spawned us. Clients that already call `start_early_prefetch()` and
// thread the result into `cfg.remote_settings` skip this entirely.
if cfg.remote_settings.is_none()
&& let Some(handle) =
crate::agent::models::start_early_prefetch(Some(cfg.grok_com_config.clone()))
{
match handle.join() {
Ok(result) => {
cfg.remote_settings = result.settings;
crate::util::config::set_remote_campaigns_from_settings(
cfg.remote_settings.as_ref(),
);
tracing::info!("remote_settings fetched as shell-level fallback");
}
Err(_) => {
tracing::warn!("remote_settings fallback prefetch thread panicked");
}
}
}
// Idempotent: bootstrap may already have fetched + applied side effects for the gate.
// Full prefetch (with managed-config sync when stale) is allowed after the gate.
ensure_remote_settings_side_effects(&mut cfg, true);
crate::util::config::sync_campaign_fields(&mut cfg);
crate::agent::config::apply_remote_settings_side_effects(cfg.remote_settings.as_ref());
// env var > remote settings > Local. Skip remote settings for Generic (grok -p, subagents).
if cfg.storage_mode == StorageMode::Local
@ -128,6 +148,12 @@ fn init_process(cfg: &AgentConfig, auth_manager: &AuthManager) {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
// Every agent mode (stdio/headless/leader and the in-process TUI
// agent) passes through here, so diagnostic uploads always carry
// the version stamp and the resource ceilings in effect.
xai_grok_telemetry::unified_log::set_version(xai_grok_version::VERSION);
crate::util::limits::log_effective_limits();
if !cfg!(test) {
// Clear a logged-out team's files before the background sync runs.
crate::managed_config::clear_orphan();

View file

@ -1570,19 +1570,31 @@ fn resolve_prefetch_env(grok_com_config: Option<GrokComConfig>) -> Option<Prefet
/// credentials from disk.
pub fn start_early_prefetch_with_auth(auth: Option<GrokAuth>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env_with_auth(auth)?;
Some(spawn_prefetch_thread(env))
Some(spawn_prefetch_thread(env, true))
}
/// Start model + settings prefetch on a background thread.
///
/// Convenience wrapper that reads cached auth from disk. Prefer
/// `start_early_prefetch_with_auth` when you have pre-resolved credentials.
/// Also runs a best-effort managed-config sync when the cache is stale.
pub fn start_early_prefetch(grok_com_config: Option<GrokComConfig>) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(grok_com_config)?;
Some(spawn_prefetch_thread(env))
Some(spawn_prefetch_thread(env, true))
}
fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
/// Prefetch models + remote settings only — **no** managed-config sync.
///
/// Used before the managed-policy gate so a kill-switch can apply on cold start
/// without healing a tampered on-disk policy before the fail-closed gate runs.
pub fn start_early_prefetch_settings_only(
grok_com_config: Option<GrokComConfig>,
) -> Option<EarlyPrefetchHandle> {
let env = resolve_prefetch_env(grok_com_config)?;
Some(spawn_prefetch_thread(env, false))
}
fn spawn_prefetch_thread(env: PrefetchEnv, sync_managed: bool) -> EarlyPrefetchHandle {
std::thread::spawn(move || {
let mut timer = crate::instrumentation_timer!("startup.early_prefetch");
let proxy_endpoint = env.endpoints.proxy_url();
@ -1592,7 +1604,9 @@ fn spawn_prefetch_thread(env: PrefetchEnv) -> EarlyPrefetchHandle {
env.auth.as_ref(),
env.model_fetch_auth,
);
if (env.endpoints.deployment_key.is_some() || crate::managed_config::has_active_team_auth())
if sync_managed
&& (env.endpoints.deployment_key.is_some()
|| crate::managed_config::has_active_team_auth())
&& crate::config::is_managed_config_stale_for(
&crate::managed_config::current_serving_identity(),
)

View file

@ -489,6 +489,16 @@ impl MvpAgent {
pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) {
self.activity = activity;
}
/// Send [`SessionCommand::Shutdown`] to every live session actor and wait
/// up to `grace` for them to exit (SessionEnd hooks, memory save, etc.).
///
/// Call on non-leader process quit **after** the cancel token fires but
/// **before** dropping the agent / exiting the process, so session actors
/// are not killed mid-hook. Mirrors the leader auto-update / relaunch
/// flush path ([`crate::agent::activity::AgentActivity::flush_all_sessions`]).
pub async fn flush_all_sessions(&self, grace: std::time::Duration) {
self.activity.flush_all_sessions(grace).await;
}
/// Install the channel that fans new session cwds into the leader's
/// `ConfigFileWatcher::watch_path`. Called once after
/// the watcher is constructed in `agent/app.rs`. In simple /
@ -3610,7 +3620,11 @@ impl MvpAgent {
let hooks_val = hooks_config.as_value();
let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir(
&hooks_val,
&format!("agent:{}", agent_definition.name),
&format!(
"{}{}",
xai_grok_hooks::config::AGENT_HOOK_PREFIX,
agent_definition.name
),
std::path::Path::new(&session_info.cwd),
);
for e in &errors {

View file

@ -835,7 +835,11 @@ pub(crate) async fn run_shell_child(
let hooks_val = hooks_config.as_value();
let (specs, errors) = xai_grok_hooks::config::parse_hooks_from_value_with_dir(
&hooks_val,
&format!("agent:{}", definition.name),
&format!(
"{}{}",
xai_grok_hooks::config::AGENT_HOOK_PREFIX,
definition.name
),
&ctx.parent_cwd,
);
for e in &errors {