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:
parent
6e38642082
commit
47348d13ec
138 changed files with 7283 additions and 5796 deletions
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue