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;