Synced from monorepo
Synced from monorepo Changes: - Refresh tool search when the managed MCP catalog is re-fetched - Prevent duplicate leader process spawn and startup hang from stale leaders - Document marketplaces, plugins, and organization controls - Stamp session ID on image generation direct-to-API requests - Fix auto mode blocked documentation - Auto mode considers recent user intent - Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API - Fail-closed auth refresh contract for shell clients - Emit a chat-supplied per-session turn index in turn hooks - Show bash mode chrome in minimal mode - Add metrics for true-noop and stationarity stops - Include voice interim text on prompt submit - Silently end turn on true-noop thrash - Quiet copy toast when clipboard delivery is confirmed - Fix session fork truncating at the wrong prompt in rewound sessions - Make the idle "still running" watcher cue clickable to open the tasks pane - Default web search model to grok-4.5 - Let plugin subagents inherit parent MCP servers - Gate no-op end-turn reminder on system reminders - Add gateway bridge lifecycle telemetry - Allow editing finalized text while voice is open - Relocate token carrier to turn-commit events and plumb per-turn origin context - Raise workflow scratch quotas and make failed runs resumable - Workflows overlay: auto-progress phases, live agent status, and drop budget meter Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
This commit is contained in:
parent
69f0ba880a
commit
6e38642082
103 changed files with 4964 additions and 1261 deletions
|
|
@ -71,6 +71,12 @@ const AUTO_UPDATE_FLUSH_GRACE: Duration = Duration::from_secs(10);
|
|||
/// the bounded-grace semantics of the `RelaunchForUpdate` drain.
|
||||
const MAX_AUTO_UPDATE_BUSY_DEFERRALS: u32 = 24;
|
||||
|
||||
/// Bounded wait for the leader flock when it is held but no socket is bound yet
|
||||
/// (a spawner mid-handoff, an old-flow client holding the flock across its ~10s
|
||||
/// spawn window, or a same-version sibling briefly holding it). Exceeds that
|
||||
/// old-flow window so a legitimately-spawning peer wins the race.
|
||||
const LEADER_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Run the auto-update checker loop.
|
||||
///
|
||||
/// Periodically calls `check_fn` to check for, download, and install updates.
|
||||
|
|
@ -906,17 +912,16 @@ fn spawn_leader_relay(
|
|||
/// 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`).
|
||||
///
|
||||
/// Startup sequence:
|
||||
/// 1. Lock acquisition check — bail if another leader is already running.
|
||||
/// Startup sequence (lock-then-socket):
|
||||
/// 1. Acquire the leader flock FIRST — bail if another process holds it.
|
||||
/// 2. Socket cleanup, channel + readiness-watch creation.
|
||||
/// 3. IPC server started (`tokio::spawn`) — socket bound HERE, before auth.
|
||||
/// 4. Wait for socket to appear (fast: < 100 ms).
|
||||
/// 5. Lock handoff with spawner (if launched via connect_or_spawn).
|
||||
/// 6. Auth + model prefetch (slow path, but socket already available to clients).
|
||||
/// 5. Auth + model prefetch (slow path, but socket already available to clients).
|
||||
/// - Auth resolves non-interactively; `None` (BYOK / no session) is not an
|
||||
/// error — the relay is gated off and login is deferred to ACP.
|
||||
/// 7. `ready_tx.send(true)` — unblocks ACP forwarding in the IPC server.
|
||||
/// 8. LocalSet: agent, IPC↔agent bridges, WS↔agent bridges, relay, config watcher.
|
||||
/// 6. `ready_tx.send(true)` — unblocks ACP forwarding in the IPC server.
|
||||
/// 7. LocalSet: agent, IPC↔agent bridges, WS↔agent bridges, relay, config watcher.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
|
@ -934,7 +939,7 @@ pub async fn run_leader(
|
|||
) -> anyhow::Result<()> {
|
||||
use crate::agent::relay::RelayConfig;
|
||||
use crate::leader::{
|
||||
LeaderLock, LeaderServerControlState, LeaderServerMetadata, ShutdownReason,
|
||||
LeaderLock, LeaderServerControlState, LeaderServerMetadata, LockError, ShutdownReason,
|
||||
compute_ws_url_suffix, run_leader_server,
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
|
@ -964,41 +969,62 @@ pub async fn run_leader(
|
|||
let mut lock = LeaderLock::new(ws_url);
|
||||
let socket_path = lock.socket_path().clone();
|
||||
|
||||
// Early bail-out: lock held + socket exists → another leader is running.
|
||||
// ── Phase 1: Acquire the leader flock FIRST (lock-then-socket) ────────────
|
||||
//
|
||||
// Three cases:
|
||||
// - Lock free → we ARE the leader; hold lock through setup.
|
||||
// - Lock held + socket → another leader running → bail out immediately.
|
||||
// - Lock held + no socket → spawner (connect_or_spawn) holds lock and is
|
||||
// waiting for our socket → proceed normally.
|
||||
let lock_already_held = match lock.try_acquire() {
|
||||
// SINGLE-LEADER INVARIANT: only the flock holder may create/remove the socket
|
||||
// and it holds the flock for its whole lifetime, so a racing leader can never
|
||||
// clobber a live socket.
|
||||
match lock.try_acquire() {
|
||||
Ok(true) => {
|
||||
lock.write_pid()?;
|
||||
debug!("Lock acquired immediately, proceeding as leader");
|
||||
true
|
||||
debug!("Acquired leader lock, proceeding as leader");
|
||||
}
|
||||
Ok(false) => {
|
||||
// Fast path: a fully-running leader (flock held AND socket bound) →
|
||||
// exit so the client adopts it.
|
||||
if crate::leader::listener_is_ready(&socket_path) {
|
||||
info!(
|
||||
"Another leader is already running (lock held, socket exists at {}). Exiting.",
|
||||
"Another process holds the leader lock with a bound socket ({}). \
|
||||
Exiting so the client adopts it.",
|
||||
socket_path.display()
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Another leader is already running at {}",
|
||||
"Another leader already holds the lock at {}",
|
||||
socket_path.display()
|
||||
));
|
||||
}
|
||||
debug!("Lock held by spawner (no socket yet), proceeding with socket-then-lock flow");
|
||||
false
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to check leader lock: {}", e)),
|
||||
};
|
||||
|
||||
// ── Phase 1: Clean up stale socket ────────────────────────────────────────
|
||||
// Held but no socket yet: a spawner is mid-handoff, or an old-flow
|
||||
// client holds the flock across its spawn window. Wait (re-opening the
|
||||
// path each poll to tolerate the old client's Drop unlinking the inode)
|
||||
// before conceding.
|
||||
match lock.acquire_reopen_timeout(LEADER_ACQUIRE_TIMEOUT).await {
|
||||
Ok(()) => {
|
||||
lock.write_pid()?;
|
||||
debug!("Acquired leader lock after bounded wait, proceeding as leader");
|
||||
}
|
||||
Err(LockError::Timeout(_)) => {
|
||||
info!(
|
||||
"Timed out waiting for the leader lock ({}). Exiting so the \
|
||||
client adopts whoever won it.",
|
||||
socket_path.display()
|
||||
);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Timed out acquiring leader lock at {}",
|
||||
socket_path.display()
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e)),
|
||||
}
|
||||
|
||||
// ── Phase 2: Clean up stale socket (we hold the flock, so this is safe) ────
|
||||
lock.cleanup_socket()?;
|
||||
info!("Leader server starting");
|
||||
|
||||
// ── Phase 2: Create all channels + readiness watch ────────────────────────
|
||||
// ── Phase 3: Create all channels + readiness watch ────────────────────────
|
||||
//
|
||||
// All channels are created here so the IPC server can start receiving
|
||||
// client connections immediately, before auth/prefetch begin.
|
||||
|
|
@ -1058,7 +1084,7 @@ pub async fn run_leader(
|
|||
// Cloned before control_state moves into the IPC server; auth wired below.
|
||||
let workspace_control = control_state.workspace.clone();
|
||||
|
||||
// ── Phase 3: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
|
||||
// ── Phase 4: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
|
||||
//
|
||||
// Starting the server here means connect_or_spawn sees the socket in < 100 ms
|
||||
// regardless of how long auth + model prefetch take. The `ready_rx` gate inside
|
||||
|
|
@ -1092,7 +1118,7 @@ pub async fn run_leader(
|
|||
}
|
||||
});
|
||||
|
||||
// ── Phase 4: Wait for socket to appear (fast: < 100 ms now) ──────────────
|
||||
// ── Phase 5: Wait for socket to appear (fast: < 100 ms now) ──────────────
|
||||
let socket_ready_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::leader::listener_is_ready(&socket_path) {
|
||||
if tokio::time::Instant::now() >= socket_ready_deadline {
|
||||
|
|
@ -1105,42 +1131,8 @@ pub async fn run_leader(
|
|||
}
|
||||
debug!("IPC socket created");
|
||||
|
||||
// ── Phase 5: Lock handoff ─────────────────────────────────────────────────
|
||||
//
|
||||
// (a) lock_already_held=true: We acquired the lock at startup. Keep it.
|
||||
// (b) lock_already_held=false: spawner holds lock, waiting for our socket.
|
||||
// Now that socket is up, the spawner will see it, connect, and release
|
||||
// the lock. We acquire it here (30 s timeout).
|
||||
let _lock = if lock_already_held {
|
||||
info!("Leader lock already held from startup, PID already written");
|
||||
lock
|
||||
} else {
|
||||
const LEADER_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// spawn_blocking so we don't stall the async runtime while waiting.
|
||||
let lock_result = tokio::task::spawn_blocking(move || {
|
||||
lock.try_acquire_timeout(LEADER_LOCK_TIMEOUT)?;
|
||||
lock.write_pid()?;
|
||||
Ok::<_, anyhow::Error>(lock)
|
||||
})
|
||||
.await;
|
||||
|
||||
match lock_result {
|
||||
Ok(Ok(lock)) => {
|
||||
info!("Leader lock acquired, PID written");
|
||||
lock
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(error = ?e, "Failed to acquire leader lock");
|
||||
cancel.cancel();
|
||||
return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "Lock task panicked");
|
||||
cancel.cancel();
|
||||
return Err(anyhow::anyhow!("Lock task failed: {}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Keep `lock` alive so its `Drop` removes the lock + socket on exit.
|
||||
let _lock = lock;
|
||||
|
||||
// ── Phase 6: Auth + model prefetch ───────────────────────────────────────
|
||||
//
|
||||
|
|
|
|||
|
|
@ -3073,6 +3073,9 @@ impl acp::Agent for MvpAgent {
|
|||
crate::session::commands::PromptCompletionKind::MaxTurnsReached {
|
||||
..
|
||||
} => Some("max_turns_reached".to_string()),
|
||||
crate::session::commands::PromptCompletionKind::StationarityEnded => {
|
||||
Some("action_stationarity".to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(
|
||||
|
|
|
|||
|
|
@ -272,6 +272,81 @@ impl MvpAgent {
|
|||
}
|
||||
});
|
||||
}
|
||||
/// Push a fresh legacy managed-MCP catalog into live sessions' per-session
|
||||
/// `McpServers` (called after `mcp/list` with `cache=false`).
|
||||
///
|
||||
/// The per-session `merge_managed_mcp_servers` re-reads disk, so the whole
|
||||
/// broadcast is deferred off the `mcp/list` response-latency path via
|
||||
/// `spawn_local`. This ONLY re-merges/pushes connectors; rebuilding the
|
||||
/// agent-level gateway catalog's `search_tool` index is a separate,
|
||||
/// independently-gated broadcast (see `refresh_mcp_search_index_in_sessions`),
|
||||
/// because the two run in mutually-exclusive modes (legacy fetch only when
|
||||
/// gateway tools are OFF, gateway fetch only when ON).
|
||||
/// Caller must confirm the managed fetch succeeded (cache `Ready`) first: a
|
||||
/// failed fetch returns an empty vec and syncing it tears down live servers.
|
||||
pub(crate) fn sync_fresh_managed_mcp_to_sessions(
|
||||
&self,
|
||||
managed: &[crate::session::managed_mcp::ManagedMcpConfig],
|
||||
) {
|
||||
let sessions: Vec<_> = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.values()
|
||||
.map(|handle| (
|
||||
handle.cmd_tx.clone(),
|
||||
handle.info.cwd.clone(),
|
||||
handle.initial_client_mcp_servers.clone(),
|
||||
))
|
||||
.collect();
|
||||
if sessions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let compat = self.cfg.borrow().compat_resolved;
|
||||
let plugin_snapshot = self.plugin_registry_handle.snapshot();
|
||||
let managed = managed.to_vec();
|
||||
tokio::task::spawn_local(async move {
|
||||
let mut updated = 0u32;
|
||||
for (cmd_tx, cwd, initial_client_mcp_servers) in sessions {
|
||||
let cwd = std::path::PathBuf::from(cwd);
|
||||
if crate::session::managed_mcp::merge_and_send_managed_mcp_update(
|
||||
&cmd_tx,
|
||||
&cwd,
|
||||
initial_client_mcp_servers,
|
||||
&managed,
|
||||
plugin_snapshot.as_deref(),
|
||||
&compat,
|
||||
) {
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
if updated > 0 {
|
||||
tracing::info!(
|
||||
updated,
|
||||
managed_count = managed.len(),
|
||||
"synced fresh managed MCP catalog into live sessions"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Rebuild `search_tool` in every live session after a fresh gateway tool
|
||||
/// catalog committed.
|
||||
///
|
||||
/// Gateway tools live in the agent-level catalog (not per-session
|
||||
/// `McpServers`), so a fresh gateway catalog needs a session-side
|
||||
/// `search_tool` rebuild even though the legacy managed cache stays
|
||||
/// `NotFetched` in gateway mode. Callers gate on a successful refetch and
|
||||
/// skip on failure to keep the last-good index.
|
||||
pub(crate) fn refresh_mcp_search_index_in_sessions(&self) {
|
||||
let session_txs: Vec<_> = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.values()
|
||||
.map(|handle| handle.cmd_tx.clone())
|
||||
.collect();
|
||||
for tx in session_txs {
|
||||
let _ = tx.send(SessionCommand::RefreshMcpSearchIndex);
|
||||
}
|
||||
}
|
||||
/// Resolve the launch dir's project-scope trust verdict ONCE and return it
|
||||
/// with its path.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -333,20 +333,14 @@ async fn reload_project_servers_after_grant(ctx: ReloadAfterGrant<'_>) {
|
|||
// MCP: `merge_managed_mcp_servers` re-reads disk + runs
|
||||
// `filter_untrusted_project_mcp`, which now KEEPS project servers because
|
||||
// the cached verdict was flipped to trusted (same workspace key).
|
||||
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
|
||||
target.initial_client_mcp_servers,
|
||||
let _ = crate::session::managed_mcp::merge_and_send_managed_mcp_update(
|
||||
&target.cmd_tx,
|
||||
session_cwd,
|
||||
target.initial_client_mcp_servers,
|
||||
&managed,
|
||||
plugin_snapshot.as_deref(),
|
||||
ctx.compat,
|
||||
);
|
||||
let (tx, _rx) = tokio::sync::oneshot::channel();
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::UpdateMcpServers {
|
||||
mcp_servers: merged,
|
||||
respond_to: tx,
|
||||
});
|
||||
// Plugins (+ plugin-contributed hooks) built for this session's own cwd
|
||||
// on the folder-trust verdict (mirrors `broadcast_plugin_registry_to_sessions`);
|
||||
// the grant + resolve_and_record above flipped the cached verdict to trusted.
|
||||
|
|
|
|||
|
|
@ -1606,6 +1606,77 @@ async fn ext_method_routes_auth_cleared_and_refreshes_resident_sessions() {
|
|||
})
|
||||
.await;
|
||||
}
|
||||
/// Fresh managed catalog sync must push UpdateMcpServers with the injected
|
||||
/// managed connector. The `search_tool` rebuild is a SEPARATE broadcast
|
||||
/// (`refresh_mcp_search_index_in_sessions`), so it is not asserted here.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn sync_fresh_managed_mcp_pushes_update() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let agent = build_agent_with_auth(crate::auth::GrokAuth {
|
||||
key: "eligible".into(),
|
||||
auth_mode: crate::auth::AuthMode::WebLogin,
|
||||
..crate::auth::GrokAuth::test_default()
|
||||
});
|
||||
let sid = acp::SessionId::new("sess-managed-sync");
|
||||
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
|
||||
agent.sessions.borrow_mut().insert(sid, handle);
|
||||
let managed = vec![crate::session::managed_mcp::ManagedMcpConfig {
|
||||
name: "Linear".into(),
|
||||
endpoint: "https://mcp.example.com/linear".into(),
|
||||
headers: std::collections::HashMap::from([(
|
||||
"Authorization".into(),
|
||||
"Bearer tok".into(),
|
||||
)]),
|
||||
token_expires_at: None,
|
||||
scope: None,
|
||||
scope_id: None,
|
||||
scope_name: None,
|
||||
}];
|
||||
agent.sync_fresh_managed_mcp_to_sessions(&managed);
|
||||
let first = tokio::time::timeout(std::time::Duration::from_secs(1), cmd_rx.recv())
|
||||
.await
|
||||
.expect("UpdateMcpServers should be sent")
|
||||
.expect("channel should stay open");
|
||||
let SessionCommand::UpdateMcpServers { mcp_servers, .. } = first else {
|
||||
panic!("expected UpdateMcpServers as the first synced command");
|
||||
};
|
||||
let managed_name = crate::session::managed_mcp::to_managed_name("Linear");
|
||||
let linear = mcp_servers
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
acp::McpServer::Http(http) if http.name == managed_name => Some(http),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!("merged catalog must contain managed HTTP server {managed_name}")
|
||||
});
|
||||
assert!(
|
||||
linear
|
||||
.headers
|
||||
.iter()
|
||||
.any(|h| h.name == "Authorization" && h.value == "Bearer tok"),
|
||||
"managed server must carry the injected Authorization header"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
/// The gateway-catalog refresh broadcast pushes `RefreshMcpSearchIndex` to every
|
||||
/// live session (independent of the legacy managed-connector sync).
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn refresh_mcp_search_index_broadcasts_to_sessions() {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("sess-search-index");
|
||||
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
|
||||
agent.sessions.borrow_mut().insert(sid, handle);
|
||||
agent.refresh_mcp_search_index_in_sessions();
|
||||
let cmd = tokio::time::timeout(std::time::Duration::from_secs(1), cmd_rx.recv())
|
||||
.await
|
||||
.expect("RefreshMcpSearchIndex should be sent")
|
||||
.expect("channel should stay open");
|
||||
assert!(matches!(cmd, SessionCommand::RefreshMcpSearchIndex));
|
||||
}
|
||||
/// Build a minimal MvpAgent suitable for testing extension methods.
|
||||
fn build_minimal_agent_for_tests() -> MvpAgent {
|
||||
use crate::agent::config::Config as AgentConfig;
|
||||
|
|
|
|||
|
|
@ -861,7 +861,7 @@ pub(crate) async fn run_shell_child(
|
|||
}
|
||||
}
|
||||
}
|
||||
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
|
||||
let agent_mcp_servers: Vec<_> = if !agent_owned_mcp_servers_allowed(is_plugin_agent) {
|
||||
if !definition.mcp_servers.is_empty() {
|
||||
tracing::warn!(
|
||||
agent = %definition.name,
|
||||
|
|
@ -921,19 +921,8 @@ pub(crate) async fn run_shell_child(
|
|||
})
|
||||
.collect()
|
||||
};
|
||||
let parent_mcp_pool = if is_plugin_agent {
|
||||
if ctx.parent_mcp_pool.is_some() {
|
||||
tracing::debug!(
|
||||
agent = %definition.name,
|
||||
"skipping MCP pool inheritance for plugin agent"
|
||||
);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
ctx.parent_mcp_pool
|
||||
.take()
|
||||
.and_then(|pool| filter_pool_by_inheritance(pool, &definition.mcp_inheritance))
|
||||
};
|
||||
let parent_mcp_pool =
|
||||
resolve_inherited_mcp_pool(ctx.parent_mcp_pool.take(), &definition.mcp_inheritance);
|
||||
let mcp_inherited_count = parent_mcp_pool
|
||||
.as_ref()
|
||||
.map(|p| p.len() as u32)
|
||||
|
|
|
|||
|
|
@ -1338,6 +1338,21 @@ fn durable_resume_source_for(
|
|||
model_id: meta.effective_model_id,
|
||||
})
|
||||
}
|
||||
/// Resolve the MCP pool a child subagent should import from its parent.
|
||||
///
|
||||
/// Inheritance applies to **every** agent source (built-in, user, project,
|
||||
/// and plugin). Plugin agents are not excluded: the parent already connected
|
||||
/// these servers for the session. Agent-owned `mcpServers` (spawned by the
|
||||
/// child itself) are handled separately and remain blocked for plugins.
|
||||
///
|
||||
/// Returns `None` when there is no parent pool or `inheritance` is
|
||||
/// [`McpInheritance::None`] (avoids an empty import call downstream).
|
||||
fn resolve_inherited_mcp_pool(
|
||||
parent_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
|
||||
inheritance: &xai_grok_agent::config::McpInheritance,
|
||||
) -> Option<crate::session::mcp_servers::SharedMcpPool> {
|
||||
parent_pool.and_then(|pool| filter_pool_by_inheritance(pool, inheritance))
|
||||
}
|
||||
/// Apply `McpInheritance` filtering to a parent MCP pool snapshot.
|
||||
///
|
||||
/// Returns `None` for `McpInheritance::None` (no pool at all — avoids
|
||||
|
|
@ -1374,6 +1389,14 @@ fn filter_pool_by_inheritance(
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Whether a subagent may declare its own agent-owned `mcpServers`.
|
||||
///
|
||||
/// Plugin agents cannot: untrusted packages must not spawn MCP processes or
|
||||
/// open network MCP endpoints. Parent-pool inheritance is independent and
|
||||
/// always available subject to [`McpInheritance`].
|
||||
fn agent_owned_mcp_servers_allowed(is_plugin_agent: bool) -> bool {
|
||||
!is_plugin_agent
|
||||
}
|
||||
/// Resolve a subagent type name to its `AgentDefinition`, with the parent
|
||||
/// session's CLI tool/permission overrides already applied (so the spawn path
|
||||
/// can never obtain a definition that skips them).
|
||||
|
|
|
|||
|
|
@ -2552,6 +2552,76 @@ fn filter_inheritance_except_all_servers_gives_empty() {
|
|||
let result = result.expect("Except should return Some");
|
||||
assert_eq!(result.server_names().count(), 0);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_inherited_pool_all_passes_parent_pool() {
|
||||
let pool = make_pool(&["github", "atlassian"]);
|
||||
let result = super::resolve_inherited_mcp_pool(
|
||||
Some(pool),
|
||||
&xai_grok_agent::config::McpInheritance::All,
|
||||
)
|
||||
.expect("All should return Some");
|
||||
assert_eq!(pool_names(&result), vec!["atlassian", "github"]);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_inherited_pool_none_returns_none() {
|
||||
let pool = make_pool(&["github", "atlassian"]);
|
||||
let result = super::resolve_inherited_mcp_pool(
|
||||
Some(pool),
|
||||
&xai_grok_agent::config::McpInheritance::None,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn resolve_inherited_pool_named_filters() {
|
||||
let pool = make_pool(&["github", "atlassian", "slack"]);
|
||||
let result = super::resolve_inherited_mcp_pool(
|
||||
Some(pool),
|
||||
&xai_grok_agent::config::McpInheritance::Named(vec!["atlassian".into()]),
|
||||
)
|
||||
.expect("Named should return Some");
|
||||
assert_eq!(pool_names(&result), vec!["atlassian"]);
|
||||
}
|
||||
#[test]
|
||||
fn resolve_inherited_pool_missing_parent_returns_none() {
|
||||
let result = super::resolve_inherited_mcp_pool(
|
||||
None,
|
||||
&xai_grok_agent::config::McpInheritance::All,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
/// Plugin agents must still inherit the parent pool under default
|
||||
/// `mcpInheritance: all`. The product rule is: plugins cannot *declare*
|
||||
/// mcpServers, but they do inherit already-connected parent servers.
|
||||
#[test]
|
||||
fn plugin_agents_inherit_parent_mcp_pool_by_default() {
|
||||
assert!(
|
||||
!super::agent_owned_mcp_servers_allowed(true),
|
||||
"plugin agents must not declare agent-owned mcpServers"
|
||||
);
|
||||
assert!(
|
||||
super::agent_owned_mcp_servers_allowed(false),
|
||||
"non-plugin agents may declare agent-owned mcpServers"
|
||||
);
|
||||
let pool = make_pool(&["atlassian", "github"]);
|
||||
let inherited = super::resolve_inherited_mcp_pool(
|
||||
Some(pool),
|
||||
&xai_grok_agent::config::McpInheritance::All,
|
||||
)
|
||||
.expect("plugin children inherit parent pool with mcpInheritance=all");
|
||||
assert_eq!(pool_names(&inherited), vec!["atlassian", "github"]);
|
||||
}
|
||||
#[test]
|
||||
fn plugin_agents_can_opt_out_via_mcp_inheritance_none() {
|
||||
let pool = make_pool(&["atlassian"]);
|
||||
let inherited = super::resolve_inherited_mcp_pool(
|
||||
Some(pool),
|
||||
&xai_grok_agent::config::McpInheritance::None,
|
||||
);
|
||||
assert!(
|
||||
inherited.is_none(),
|
||||
"mcpInheritance: none must drop the parent pool for every source"
|
||||
);
|
||||
}
|
||||
fn make_test_skill(
|
||||
name: &str,
|
||||
plugin: Option<&str>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue