Skip to main content

zeph_subagent/manager/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent lifecycle management: spawn, cancel, collect, and resume.
5
6mod collect;
7mod secrets;
8mod spawn;
9mod worktree;
10
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::Instant;
15
16use tokio::sync::{mpsc, watch};
17use tokio::task::JoinSet;
18use tokio_util::sync::CancellationToken;
19use zeph_common::task_supervisor::BlockingHandle;
20use zeph_common::{SkillTrustLevel, TaskSupervisor};
21use zeph_config::{ContentIsolationConfig, McpServerConfig};
22use zeph_llm::provider::Message;
23
24use crate::def::{PermissionMode, SubAgentDef};
25use crate::durable::DurableResolverSeat;
26use crate::error::SubAgentError;
27use crate::fleet::SharedFleetRegistry;
28use crate::grants::{GrantedSecret, PermissionGrants, SecretRequest};
29use crate::state::SubAgentState;
30
31/// Parent-derived state propagated to a spawned sub-agent at spawn time.
32///
33/// All fields default to empty/`None`, preserving existing behavior when callers
34/// pass `SpawnContext::default()`.
35///
36/// # Constraint propagation
37///
38/// [`max_trust_level`][Self::max_trust_level] and
39/// [`inherited_tool_allowlist`][Self::inherited_tool_allowlist] implement transitive
40/// constraint propagation: safety constraints set at orchestration time are enforced on
41/// every sub-agent in the spawn chain, regardless of nesting depth.
42///
43/// When a sub-agent spawns its own sub-agents it must forward these fields downward so
44/// that grandchild agents cannot silently receive more privileges than the original
45/// orchestration policy allowed.
46///
47/// # Examples
48///
49/// ```rust
50/// use zeph_subagent::manager::SpawnContext;
51///
52/// // Minimal context — all fields use their defaults.
53/// let ctx = SpawnContext::default();
54/// assert!(ctx.parent_messages.is_empty());
55/// assert_eq!(ctx.spawn_depth, 0);
56/// assert!(ctx.max_trust_level.is_none());
57/// assert!(ctx.inherited_tool_allowlist.is_none());
58/// ```
59#[derive(Default)]
60pub struct SpawnContext {
61    /// Recent parent conversation messages (last N turns).
62    pub parent_messages: Vec<Message>,
63    /// Parent's cancellation token for linked cancellation (foreground spawns).
64    pub parent_cancel: Option<CancellationToken>,
65    /// Parent's active provider name (for context propagation).
66    pub parent_provider_name: Option<String>,
67    /// Current spawn depth (0 = top-level agent).
68    pub spawn_depth: u32,
69    /// MCP tool names available in the parent's tool executor (for diagnostics).
70    pub mcp_tool_names: Vec<String>,
71    /// Seeded trajectory risk score from the parent sentinel (spec 050 §4).
72    ///
73    /// When `Some`, the subagent's `TrajectorySentinel` starts with this pre-seeded score
74    /// rather than `0.0`, preventing a subagent spawn from acting as a free risk reset.
75    /// The subagent loop applies this via `TrajectorySentinel::seed_score` after build.
76    pub seed_trajectory_score: Option<f32>,
77    /// Parent's content isolation config, propagated so the subagent loop can run the
78    /// same sanitizer settings on hook-replaced tool output.
79    pub content_isolation: ContentIsolationConfig,
80    /// Name of the orchestrator that spawned this subagent.
81    ///
82    /// When set, the subagent's system prompt includes an identity header naming the
83    /// orchestrator, so the subagent can validate that instructions are consistent with
84    /// the expected authority.
85    pub orchestrator_name: Option<String>,
86    /// Role or task label of the orchestrating agent (e.g., `"planner"`, `"tool-router"`).
87    ///
88    /// Injected alongside [`orchestrator_name`][Self::orchestrator_name] when both are set.
89    /// Omitted from the identity header when only `orchestrator_name` is provided.
90    pub orchestrator_role: Option<String>,
91    /// Per-session MCP servers to inject into this subagent's tool name annotations.
92    ///
93    /// The parent is responsible for connecting these servers and including them in the
94    /// `tool_executor` passed to [`SubAgentManager::spawn`]. This field only carries the
95    /// server metadata so the subagent's system prompt lists the additional tool names.
96    pub session_mcp_servers: Vec<McpServerConfig>,
97    /// Maximum trust level cap inherited from the parent agent or orchestration policy.
98    ///
99    /// When `Some(cap)`, the spawned sub-agent's effective trust level is clamped to
100    /// `min(own_trust, cap)` so that sub-agents can never receive higher privileges than
101    /// the orchestration policy originally allowed.
102    ///
103    /// # Caller responsibility for nested spawns
104    ///
105    /// This field does **not** propagate automatically. When a sub-agent itself spawns a
106    /// grandchild, it must copy this field from its own received `SpawnContext` into the
107    /// grandchild's `SpawnContext`. Passing `None` (the default) at that point means the
108    /// grandchild receives **no cap**, which is a privilege escalation if the parent was
109    /// constrained. Only the top-level session (spawned by `build_spawn_context`) correctly
110    /// leaves this `None` — that represents an unconstrained top-level entry point.
111    ///
112    /// `None` means no cap is imposed by the parent (the sub-agent's own definition
113    /// determines its trust level).
114    pub max_trust_level: Option<SkillTrustLevel>,
115    /// Tool names that this sub-agent is allowed to invoke, inherited from the parent.
116    ///
117    /// When `Some(set)`, the effective tool allowlist for the spawned agent is the
118    /// intersection of `set` and the agent's own definition policy. This prevents a
119    /// sub-agent from accessing tools that the parent is itself not allowed to use.
120    ///
121    /// # Caller responsibility for nested spawns
122    ///
123    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
124    /// automatically. When a constrained sub-agent spawns its own children, it must copy
125    /// this field from its received `SpawnContext` into the child's `SpawnContext`.
126    /// Passing `None` at that point would grant the grandchild unrestricted tool access,
127    /// defeating the original orchestration policy.
128    ///
129    /// `None` means no additional allowlist restriction is imposed by the parent
130    /// (the agent's definition policy applies without narrowing).
131    pub inherited_tool_allowlist: Option<HashSet<String>>,
132
133    /// Durable resolver seat for promise-based subagent spawn/await (spec-064 §P4, INV-9).
134    ///
135    /// When `Some`, the spawned background task resolves the parent's durable promise after the
136    /// agent loop terminates. The seat carries the resolver token and MUST NOT be forwarded to
137    /// the child's tool executor or LLM surface — only the background task wrapper consumes it.
138    ///
139    /// `None` when `durable.enabled && durable.subagent` is false (plain spawn/collect path).
140    pub durable_resolver: Option<DurableResolverSeat>,
141
142    /// Deny network egress for this sub-agent's `bash` tool calls.
143    ///
144    /// Set by the orchestration layer when the spawning `TaskNode` carries
145    /// `network_scope: NetworkScope::Deny` (spec `069-threat-model` OQ-1). When `true`,
146    /// `build_filtered_executor` wraps the tool executor with
147    /// [`NetworkDenyToolExecutor`](crate::NetworkDenyToolExecutor), which blocks `bash`
148    /// invocations of `curl`, `wget`, `nc`, `ncat`, and `netcat` for this spawn only —
149    /// sibling tasks and the parent agent's own executor are unaffected.
150    ///
151    /// # Caller responsibility for nested spawns
152    ///
153    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
154    /// automatically. A sub-agent that itself spawns a grandchild must copy this field
155    /// from its own received `SpawnContext` into the grandchild's `SpawnContext`, or the
156    /// grandchild spawns with network access regardless of the original task's scope.
157    ///
158    /// `false` (the default) imposes no restriction beyond the executor/global
159    /// `allow_network` default.
160    pub network_denied: bool,
161}
162
163/// Live status snapshot of a running sub-agent.
164///
165/// Values are updated by the background agent loop via a [`tokio::sync::watch`] channel.
166/// Callers receive snapshots via [`SubAgentManager::statuses`].
167#[derive(Debug, Clone)]
168pub struct SubAgentStatus {
169    /// Current lifecycle state of the agent task.
170    pub state: SubAgentState,
171    /// Last message content from the agent (trimmed for display).
172    pub last_message: Option<String>,
173    /// Number of LLM turns consumed so far.
174    pub turns_used: u32,
175    /// Monotonic timestamp recorded at spawn time.
176    pub started_at: Instant,
177}
178
179/// Handle to a spawned sub-agent task, owned by [`SubAgentManager`].
180///
181/// Fields are public to allow test harnesses in downstream crates to construct handles
182/// without going through the full spawn lifecycle. Production code must not mutate
183/// grants or the cancellation state directly — use the [`SubAgentManager`] API instead.
184///
185/// The `Drop` implementation cancels the task and revokes all grants as a safety net.
186pub struct SubAgentHandle {
187    /// Short display ID (same as `task_id` for non-resumed sessions).
188    pub id: String,
189    /// The definition that was used to spawn this agent.
190    pub def: SubAgentDef,
191    /// UUID assigned at spawn time (currently identical to `id`; separated for future use).
192    pub task_id: String,
193    /// Cached state — may lag the background task by one watch broadcast.
194    pub state: SubAgentState,
195    /// Supervised handle for the background agent loop task.
196    pub join_handle: Option<BlockingHandle<Result<String, SubAgentError>>>,
197    /// Cancellation token; cancelled on [`SubAgentManager::cancel`] or drop.
198    pub cancel: CancellationToken,
199    /// Watch receiver for live status updates from the agent loop.
200    pub status_rx: watch::Receiver<SubAgentStatus>,
201    /// Zero-trust TTL-bounded grants for this agent session.
202    pub grants: PermissionGrants,
203    /// Receives secret requests from the sub-agent loop.
204    pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
205    /// Delivers the approval outcome to the sub-agent loop: `None` = denied,
206    /// `Some(value)` = approved, carrying the resolved vault secret value and its
207    /// grant expiry so the loop can re-validate the TTL locally on every tool call.
208    pub secret_tx: mpsc::Sender<Option<GrantedSecret>>,
209    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
210    pub started_at_str: String,
211    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
212    pub transcript_dir: Option<PathBuf>,
213    /// MCP tool names available at spawn time, persisted for transcript meta on collect.
214    pub mcp_tool_names: Vec<String>,
215}
216
217impl SubAgentHandle {
218    /// Construct a minimal [`SubAgentHandle`] for use in unit tests.
219    ///
220    /// The returned handle has a no-op cancel token, closed channels, and no grants.
221    /// It must not be spawned or collected — it is only valid for inspection logic
222    /// that operates on the handle's metadata fields (id, def, state, etc.).
223    #[cfg(test)]
224    pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
225        let initial_status = SubAgentStatus {
226            state: SubAgentState::Working,
227            last_message: None,
228            turns_used: 0,
229            started_at: Instant::now(),
230        };
231        let (status_tx, status_rx) = watch::channel(initial_status);
232        drop(status_tx);
233        let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
234        drop(pending_secret_rx_tx);
235        let (secret_tx, _) = mpsc::channel(1);
236        let id_str = id.into();
237        Self {
238            task_id: id_str.clone(),
239            id: id_str,
240            def,
241            state: SubAgentState::Working,
242            join_handle: None,
243            cancel: CancellationToken::new(),
244            status_rx,
245            grants: PermissionGrants::default(),
246            pending_secret_rx,
247            secret_tx,
248            started_at_str: String::new(),
249            transcript_dir: None,
250            mcp_tool_names: Vec::new(),
251        }
252    }
253}
254
255impl std::fmt::Debug for SubAgentHandle {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        f.debug_struct("SubAgentHandle")
258            .field("id", &self.id)
259            .field("task_id", &self.task_id)
260            .field("state", &self.state)
261            .field("def_name", &self.def.name)
262            .finish_non_exhaustive()
263    }
264}
265
266impl Drop for SubAgentHandle {
267    fn drop(&mut self) {
268        // Defense-in-depth: cancel the task and revoke grants on drop even if
269        // cancel() or collect() was not called (e.g., on panic or early return).
270        self.cancel.cancel();
271        if !self.grants.is_empty_grants() {
272            tracing::warn!(
273                id = %self.id,
274                "SubAgentHandle dropped without explicit cleanup — revoking grants"
275            );
276        }
277        self.grants.revoke_all();
278    }
279}
280
281/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
282///
283/// `SubAgentManager` is the central coordinator for all sub-agent tasks. It tracks active
284/// [`SubAgentHandle`]s, enforces the global concurrency limit, and stores loaded
285/// [`SubAgentDef`]s.
286///
287/// # Concurrency model
288///
289/// The concurrency limit counts agents whose [`SubAgentState`] is `Submitted` or `Working`.
290/// Reserved slots (via [`reserve_slots`][Self::reserve_slots]) also count against this limit
291/// to allow orchestration schedulers to guarantee capacity before spawning.
292///
293/// # Examples
294///
295/// ```rust
296/// use zeph_subagent::SubAgentManager;
297///
298/// let manager = SubAgentManager::new(4);
299/// assert_eq!(manager.definitions().len(), 0);
300/// ```
301pub struct SubAgentManager {
302    definitions: Vec<SubAgentDef>,
303    agents: HashMap<String, SubAgentHandle>,
304    max_concurrent: usize,
305    /// Number of slots soft-reserved by the orchestration scheduler.
306    ///
307    /// Reserved slots count against the concurrency limit so that the scheduler can
308    /// guarantee capacity for tasks it is about to spawn, preventing a planning-phase
309    /// sub-agent from exhausting the pool and causing a deadlock.
310    reserved_slots: usize,
311    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
312    stop_hooks: Vec<super::hooks::HookDef>,
313    /// Directory for JSONL transcripts and meta sidecars.
314    transcript_dir: Option<PathBuf>,
315    /// Maximum number of transcript files to keep (0 = unlimited).
316    transcript_max_files: usize,
317    /// Optional fleet registry for registering sub-agents in the fleet dashboard.
318    ///
319    /// When `None`, fleet registration is skipped silently. Inject via
320    /// [`set_fleet_registry`][Self::set_fleet_registry].
321    fleet_registry: Option<SharedFleetRegistry>,
322    /// Tracks fire-and-forget hook and fleet-registry tasks to prevent silent panic swallowing.
323    ///
324    /// Completed and panicked tasks are drained before each new spawn. On graceful shutdown,
325    /// [`shutdown_all`][Self::shutdown_all] aborts all outstanding tasks via
326    /// [`JoinSet::shutdown`].
327    hook_tasks: JoinSet<()>,
328    /// Maximum number of concurrent hook tasks allowed in [`hook_tasks`][Self::hook_tasks].
329    ///
330    /// When the limit is reached, new fire-and-forget tasks are dropped with a warning instead
331    /// of growing the set unboundedly under high-throughput spawning.
332    max_hook_tasks: usize,
333    /// Optional worktree manager; `Some` iff `worktree.enabled = true` in config.
334    ///
335    /// When set, every [`spawn`][Self::spawn] acquires [`cwd_lock`][Self::cwd_lock] for
336    /// its full run so that plain agents cannot observe a stale cwd mutated by a worktree
337    /// agent (INV-1). Only agents with `permissions.worktree = true` and a non-`None`
338    /// `bg_isolation` actually get a dedicated worktree.
339    ///
340    /// This is the single live instance shared by the running agent's `/worktree`
341    /// slash command (see [`worktree_manager`][Self::worktree_manager]) — distinct from
342    /// the CLI's `zeph worktree list`/`clean`, which constructs its own fresh manager
343    /// per invocation (`src/commands/worktree.rs`).
344    worktree_manager: Option<Arc<zeph_worktree::DefaultWorktreeManager>>,
345    /// Process-level serialisation mutex for working-directory mutations (INV-1).
346    ///
347    /// Acquired by every spawned task when `worktree_manager.is_some()`.  The
348    /// `OwnedMutexGuard` is held for the full duration of `run_agent_loop` via the
349    /// `CwdRestoreGuard` RAII wrapper.
350    cwd_lock: Arc<tokio::sync::Mutex<()>>,
351    /// Optional supervisor for subagent lifecycle tasks.
352    ///
353    /// When set, each spawned agent loop task is registered under its task ID so it is
354    /// visible to TUI status panels and shutdown is coordinated through the supervisor.
355    task_supervisor: Option<TaskSupervisor>,
356}
357
358impl std::fmt::Debug for SubAgentManager {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        f.debug_struct("SubAgentManager")
361            .field("definitions_count", &self.definitions.len())
362            .field("active_agents", &self.agents.len())
363            .field("max_concurrent", &self.max_concurrent)
364            .field("reserved_slots", &self.reserved_slots)
365            .field("stop_hooks_count", &self.stop_hooks.len())
366            .field("transcript_dir", &self.transcript_dir)
367            .field("transcript_max_files", &self.transcript_max_files)
368            .field("fleet_registry", &self.fleet_registry.is_some())
369            .field("hook_tasks_len", &self.hook_tasks.len())
370            .field("max_hook_tasks", &self.max_hook_tasks)
371            .field("worktree_manager", &self.worktree_manager.is_some())
372            .field("cwd_lock", &"<Mutex>")
373            .field("task_supervisor", &self.task_supervisor.is_some())
374            .finish()
375    }
376}
377
378impl SubAgentManager {
379    /// Create a new manager with the given concurrency limit.
380    #[must_use]
381    pub fn new(max_concurrent: usize) -> Self {
382        Self {
383            definitions: Vec::new(),
384            agents: HashMap::new(),
385            max_concurrent,
386            reserved_slots: 0,
387            stop_hooks: Vec::new(),
388            transcript_dir: None,
389            transcript_max_files: 50,
390            fleet_registry: None,
391            hook_tasks: JoinSet::new(),
392            max_hook_tasks: 64,
393            worktree_manager: None,
394            cwd_lock: Arc::new(tokio::sync::Mutex::new(())),
395            task_supervisor: None,
396        }
397    }
398
399    /// Inject a [`TaskSupervisor`] so subagent lifecycle tasks are registered and visible.
400    ///
401    /// Must be called before the first [`spawn`][Self::spawn]. When set, each spawned agent
402    /// loop task is registered under its task ID and is observable in TUI status panels and
403    /// [`TaskSupervisor::snapshot`].
404    pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
405        self.task_supervisor = Some(supervisor);
406    }
407
408    /// Inject a [`DefaultWorktreeManager`][zeph_worktree::DefaultWorktreeManager] into the
409    /// manager.
410    ///
411    /// Must be called at most once, before the first [`spawn`][Self::spawn].  When set,
412    /// every spawned task acquires the process-level cwd mutex (INV-1) and agents with
413    /// `permissions.worktree = true` receive a dedicated git worktree.
414    pub fn set_worktree_manager(&mut self, wm: Arc<zeph_worktree::DefaultWorktreeManager>) {
415        self.worktree_manager = Some(wm);
416    }
417
418    /// Returns the live worktree manager, if the worktree subsystem is enabled for this
419    /// session.
420    ///
421    /// This is the same instance [`spawn`][Self::spawn] uses to create per-subagent
422    /// worktrees, so callers (e.g. the `/worktree` slash command) observe this session's
423    /// actual live state rather than a fresh disk scan. Its own
424    /// `prune_branch_on_remove()` reflects `WorktreeConfig::prune_branch_on_remove` — no
425    /// need to retain a separate copy on `SubAgentManager`.
426    #[must_use]
427    pub fn worktree_manager(&self) -> Option<&Arc<zeph_worktree::DefaultWorktreeManager>> {
428        self.worktree_manager.as_ref()
429    }
430
431    /// Drain completed hook tasks and spawn a new one if below the limit.
432    ///
433    /// Polls [`hook_tasks`][Self::hook_tasks] for finished entries so the set does not
434    /// accumulate stale handles. When the set is at capacity, logs a warning and skips
435    /// the spawn rather than growing unboundedly.
436    fn spawn_hook_task<F>(&mut self, future: F)
437    where
438        F: std::future::Future<Output = ()> + Send + 'static,
439    {
440        // Drain completed/panicked tasks before checking capacity.
441        while self.hook_tasks.try_join_next().is_some() {}
442        if self.hook_tasks.len() >= self.max_hook_tasks {
443            tracing::warn!(
444                limit = self.max_hook_tasks,
445                "hook task limit reached — dropping fire-and-forget task"
446            );
447            return;
448        }
449        self.hook_tasks.spawn(future);
450    }
451
452    /// Spawns a named subagent task under the session [`TaskSupervisor`] if one is configured,
453    /// making the task visible in TUI status and abortable on shutdown via
454    /// [`TaskSupervisor::shutdown_all`].
455    ///
456    /// Falls back to a transient local supervisor when no session supervisor has been wired via
457    /// [`SubAgentManager::set_task_supervisor`] — the task runs but is not tracked globally.
458    /// The returned [`BlockingHandle`] type is identical in both cases so call sites are uniform.
459    ///
460    /// Every agent loop task's future resolves to a `Result<T, E>` (in practice always
461    /// `Result<String, SubAgentError>`), so this classifies via
462    /// [`TaskSupervisor::spawn_oneshot_classified`] rather than plain `spawn_oneshot` — an
463    /// `Err` produced by a genuinely completed task (e.g. a worktree-quota or cwd-guard setup
464    /// failure returned before the agent loop ever starts) is thus classified and logged as a
465    /// supervisor-level failure instead of a normal completion (#6257).
466    pub(crate) fn spawn_agent_task<F, Fut, T, E>(
467        &self,
468        name: Arc<str>,
469        factory: F,
470    ) -> BlockingHandle<Result<T, E>>
471    where
472        F: FnOnce() -> Fut + Send + 'static,
473        Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
474        T: Send + 'static,
475        E: Send + 'static,
476    {
477        if let Some(ref sup) = self.task_supervisor {
478            sup.spawn_oneshot_classified(name, factory, Result::is_ok)
479        } else {
480            let local = TaskSupervisor::new(CancellationToken::new());
481            local.spawn_oneshot_classified(name, factory, Result::is_ok)
482        }
483    }
484
485    /// Reserve `n` concurrency slots for the orchestration scheduler.
486    ///
487    /// Reserved slots count against the concurrency limit in [`spawn`](Self::spawn) so that
488    /// the scheduler can guarantee capacity for tasks it is about to launch. Call
489    /// [`release_reservation`](Self::release_reservation) when the scheduler finishes.
490    pub fn reserve_slots(&mut self, n: usize) {
491        self.reserved_slots = self.reserved_slots.saturating_add(n);
492    }
493
494    /// Release `n` previously reserved concurrency slots.
495    pub fn release_reservation(&mut self, n: usize) {
496        self.reserved_slots = self.reserved_slots.saturating_sub(n);
497    }
498
499    /// Configure transcript storage settings.
500    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
501        self.transcript_dir = dir;
502        self.transcript_max_files = max_files;
503    }
504
505    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
506    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
507        self.stop_hooks = hooks;
508    }
509
510    /// Inject a fleet registry so spawned sub-agents appear in the fleet dashboard.
511    ///
512    /// When set, [`spawn`][Self::spawn] registers the session as `Active` and
513    /// [`collect`][Self::collect] / [`cancel`][Self::cancel] mark it terminal.
514    /// Errors from the registry are logged at `warn` level and never propagate to callers.
515    pub fn set_fleet_registry(&mut self, registry: SharedFleetRegistry) {
516        self.fleet_registry = Some(registry);
517    }
518
519    /// Load sub-agent definitions from the given directories.
520    ///
521    /// Higher-priority directories should appear first. Name conflicts are resolved
522    /// by keeping the first occurrence. Non-existent directories are silently skipped.
523    ///
524    /// # Errors
525    ///
526    /// Returns [`SubAgentError`] if any definition file fails to parse.
527    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
528        let defs = SubAgentDef::load_all(dirs)?;
529
530        // Security gate: non-Default permission_mode is forbidden when the user-level
531        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
532        // a crafted agent file from escalating its own privileges.
533        // Validation happens here (in the manager) because this is the only place
534        // that has full context about which directories were searched.
535        //
536        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
537        // non-Default permission_mode, we cannot verify it did not originate from the
538        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
539        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
540        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
541            // FIX-8: log and treat as non-user-level if canonicalize fails.
542            match std::fs::canonicalize(user_dir) {
543                Ok(canonical_user) => dirs
544                    .iter()
545                    .filter_map(|d| std::fs::canonicalize(d).ok())
546                    .any(|d| d == canonical_user),
547                Err(e) => {
548                    tracing::warn!(
549                        dir = %user_dir.display(),
550                        error = %e,
551                        "could not canonicalize user agents dir, treating as non-user-level"
552                    );
553                    false
554                }
555            }
556        });
557
558        if loads_user_dir {
559            for def in &defs {
560                if def.permissions.permission_mode != PermissionMode::Default {
561                    return Err(SubAgentError::Invalid(format!(
562                        "sub-agent '{}': non-default permission_mode is not allowed for \
563                         user-level definitions (~/.zeph/agents/)",
564                        def.name
565                    )));
566                }
567            }
568        }
569
570        self.definitions = defs;
571        tracing::info!(
572            count = self.definitions.len(),
573            "sub-agent definitions loaded"
574        );
575        Ok(())
576    }
577
578    /// Load definitions with full scope context for source tracking and security checks.
579    ///
580    /// The blocking filesystem scan runs on a dedicated thread via
581    /// `tokio::task::spawn_blocking` so the tokio worker thread is not stalled (#5108).
582    ///
583    /// # Errors
584    ///
585    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
586    #[tracing::instrument(name = "subagent.manager.load_definitions_with_sources", skip_all)]
587    pub async fn load_definitions_with_sources(
588        &mut self,
589        ordered_paths: &[PathBuf],
590        cli_agents: &[PathBuf],
591        config_user_dir: Option<&PathBuf>,
592        extra_dirs: &[PathBuf],
593    ) -> Result<(), SubAgentError> {
594        // Clone inputs so they can be moved into spawn_blocking ('static bound).
595        let ordered = ordered_paths.to_vec();
596        let cli = cli_agents.to_vec();
597        let user_dir = config_user_dir.cloned();
598        let extra = extra_dirs.to_vec();
599
600        let defs = tokio::task::spawn_blocking(move || {
601            SubAgentDef::load_all_with_sources(&ordered, &cli, user_dir.as_ref(), &extra)
602        })
603        .await
604        .map_err(|e| SubAgentError::TaskPanic(format!("load_definitions_with_sources: {e}")))?;
605
606        self.definitions = defs?;
607        tracing::info!(
608            count = self.definitions.len(),
609            "sub-agent definitions loaded"
610        );
611        Ok(())
612    }
613
614    /// Return all loaded definitions.
615    #[must_use]
616    pub fn definitions(&self) -> &[SubAgentDef] {
617        &self.definitions
618    }
619
620    /// Return mutable access to the loaded definitions list.
621    ///
622    /// Intended for test harnesses and dynamic definition registration. Production code
623    /// should prefer [`load_definitions`][Self::load_definitions].
624    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
625        &mut self.definitions
626    }
627
628    /// Insert a pre-built handle directly into the active agents map.
629    ///
630    /// Used in tests to simulate an agent that has already run and left a pending secret
631    /// request in its channel without going through the full spawn lifecycle.
632    pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
633        self.agents.insert(id, handle);
634    }
635}
636
637#[cfg(test)]
638mod tests;