返回 CodeWhale
exact.rs
根目录 / crates / tui / src / fleet / exact.rs
1 //! Runtime for an **exact named Fleet** (`schema = "exact"`).
2 //!
3 //! The saved Fleet is the Fleet that runs. At Workflow start its definition is
4 //! read from the standard `FleetSearchRoot` locations, every worker route is
5 //! **preflighted and frozen**, the attached Reasoning Router service is
6 //! resolved, and the whole thing is captured into an immutable
7 //! [`FleetSnapshot`] projected onto the roster/profile machinery the in-process
8 //! spawn path already uses.
9 //!
10 //! Five invariants govern everything below.
11 //!
12 //! 1. **Routes freeze first, and are checked while freezing.** Provider
13 //! identity, canonical wire model, endpoint, local credential readiness, and
14 //! reasoning capability are all resolved before the Workflow starts — and
15 //! certainly before any Router is asked anything. Nothing downstream may
16 //! move them: not a task option, not the Router.
17 //! 2. **Admission comes before cost.** A task is resolved against the roster,
18 //! checked against gates, and given a concurrency slot *before* the Router
19 //! is called. A rejected or capacity-blocked task spends no Router tokens
20 //! and discloses nothing to a Router's provider.
21 //! 3. **Auto is a reasoning decision, and the attached Router makes it.**
22 //! `reasoning = "auto"` always goes to the Fleet's Reasoning Router — no
23 //! provider-native-adaptive bypass, no legacy model routing, no local
24 //! keyword heuristic. A manual tier calls no Router at all.
25 //! 4. **Runtime owns authority.** After exact member selection, Runtime maps
26 //! the semantic role onto its closed role policy and intersects that policy
27 //! with the live parent. Fleet identity never grants or withholds project
28 //! trust, tools, writes, network reach, shell, or delegation.
29 //! 5. **Receipts are truthful and content-free.** The tier a selector picked,
30 //! the control a provider actually receives, and what a Router cost are
31 //! recorded separately; task text never is.
32
33 use std::sync::Arc;
34
35 use async_trait::async_trait;
36 #[cfg(test)]
37 use codewhale_workflow::ShellCeiling;
38 use codewhale_workflow::{
39 CapturedReasoningRouter, CredentialReadiness, EffectiveReasoning, EndpointIdentity,
40 FleetDocument, FleetRouterRef, FleetSearchRoot, FleetSnapshot, FleetSnapshotMember,
41 FleetTaskReceipt, NamedFleetError, PermissionCeiling, PreflightError, PreflightedRoute,
42 ProviderReasoningControl, QualifiedFleetId, ReasoningCapability, ReasoningRouterProfile,
43 ReasoningTier, ResolvedReasoning, RoutePreflight, RouterAvailability, RouterCallInput,
44 RouterCallPlan, RouterIdentity, RoutingDisclosure, bounded_routing_payload,
45 captured_legacy_inline_router, parse_router_decision, resolve_exact_member_reasoning,
46 router_call_plan, router_system_prompt, router_user_message,
47 };
48
49 use super::role::{ChildAuthority, public_role_label};
50 #[cfg(test)]
51 use super::role::{
52 NETWORK_DENIAL_SENTINEL, NETWORK_TOOL_DENYLIST, RAW_SHELL_SENTINEL, is_posture_denial,
53 session_shell_ceiling,
54 };
55 use crate::config::{Config, ProviderKind};
56 use crate::llm_client::LlmClient;
57 use crate::reasoning_preference::ReasoningEffort;
58 use codewhale_models::Role;
59
60 /// Where exact Fleet definitions and Reasoning Router profiles are looked up,
61 /// labelled so an identity can be qualified (`workspace/glm-pair`) instead of
62 /// silently shadowed.
63 fn personal_fleet_root() -> anyhow::Result<std::path::PathBuf> {
64 codewhale_config::codewhale_home()
65 }
66
67 pub(crate) fn personal_fleet_definitions_dir() -> anyhow::Result<std::path::PathBuf> {
68 Ok(personal_fleet_root()?.join("fleets"))
69 }
70
71 /// The workspace has two origins. `workspace` is `<workspace>/.codewhale`, the
72 /// directory the Fleet store saves workspace-scoped Fleets to, so a Fleet
73 /// saved from the Fleet UI is found by name. `workspace_root` is the workspace
74 /// directory itself, which keeps checked-in `fleets/<name>.toml` rosters
75 /// loading as they always have.
76 #[must_use]
77 pub(crate) fn fleet_search_roots(workspace: &std::path::Path) -> Vec<FleetSearchRoot> {
78 let mut roots = Vec::new();
79 if let Ok(home) = personal_fleet_root() {
80 roots.push(FleetSearchRoot::new("codewhale_home", home));
81 }
82 roots.push(FleetSearchRoot::new(
83 "workspace",
84 workspace.join(".codewhale"),
85 ));
86 roots.push(FleetSearchRoot::new(
87 "workspace_root",
88 workspace.to_path_buf(),
89 ));
90 roots
91 }
92
93 /// Load a Fleet document by (optionally qualified) name from the standard
94 /// roots. Ambiguity between origins is surfaced, never resolved by shadowing.
95 ///
96 /// Saved v2 Fleets (`schema = "fleet"`, from `.codewhale/fleets/` or
97 /// `$CODEWHALE_HOME/fleets/`) are looked up first and frozen into an exact
98 /// snapshot here — see [`freeze_saved_fleet`]. A miss falls through to the
99 /// workflow crate's legacy/exact loader. A bare name that exists both as a v2
100 /// Fleet and as a legacy/exact file is ambiguous: neither shadows the other,
101 /// and the error names every path. v2 Fleets qualify as `user/<name>` and
102 /// `folder/<name>` (the store's own scope labels); a search-root origin
103 /// (`codewhale_home/`, `workspace/`, `workspace_root/`) reads that root's file
104 /// in whichever form it is.
105 ///
106 /// `config` is the session config the caller preflights with: inheriting
107 /// members resolve against it at this point, immediately before the same
108 /// config preflights the frozen routes, so a receipt names the route that ran.
109 ///
110 /// Synchronous file loading: async callers must run this on the blocking pool.
111 pub(crate) fn load_fleet_document(
112 name: &str,
113 workspace: &std::path::Path,
114 config: Option<&Config>,
115 ) -> Result<(FleetDocument, QualifiedFleetId), NamedFleetError> {
116 use super::store::{self, FleetScope};
117
118 let roots = fleet_search_roots(workspace);
119 // Validates the bare name before any path below is built from it.
120 let (origin, bare) = codewhale_workflow::split_qualified_fleet_name(name)?;
121 let store_error = |error: store::FleetStoreError| match error {
122 store::FleetStoreError::NotFound(what) => NamedFleetError::NotFound(what),
123 store::FleetStoreError::Io { path, message } => NamedFleetError::Io { path, message },
124 store::FleetStoreError::Parse { path, message } => NamedFleetError::Parse { path, message },
125 other => NamedFleetError::Parse {
126 path: bare.to_string(),
127 message: other.to_string(),
128 },
129 };
130 let v2_scope = match origin.map(str::to_ascii_lowercase).as_deref() {
131 None => None,
132 Some("user" | "personal") => Some(FleetScope::Personal),
133 Some("folder") => Some(FleetScope::Workspace),
134 // Any other origin names a legacy/exact search root. A saved v2
135 // Fleet can live there too (the personal `fleets/` directory is
136 // shared), so the qualified file is read in whichever form it is.
137 Some(origin) => {
138 let saved = roots
139 .iter()
140 .find(|root| root.origin.eq_ignore_ascii_case(origin))
141 .map(|root| {
142 root.root
143 .join(store::FLEET_DIR)
144 .join(format!("{bare}.toml"))
145 })
146 .filter(|path| store::declares_v2_schema(path));
147 let Some(path) = saved else {
148 return FleetDocument::load_by_name(name, &roots);
149 };
150 let (fleet, scope) = store::load_fleet_at(&path).map_err(store_error)?;
151 return freeze_saved_fleet(&fleet, scope, &path, config);
152 }
153 };
154
155 if let Some(scope) = v2_scope {
156 let (fleet, path) =
157 store::load_fleet_in_scope(bare, scope, workspace).map_err(store_error)?;
158 return freeze_saved_fleet(&fleet, scope, &path, config);
159 }
160
161 // Legacy/exact files under the same bare name, in any root. A v2 file in
162 // the shared personal directory is the store's, not a second Fleet.
163 let file_name = format!("{bare}.toml");
164 let other_forms: Vec<String> = roots
165 .iter()
166 .filter_map(|root| {
167 let path = root.root.join(store::FLEET_DIR).join(&file_name);
168 let schema = store::read_declared_schema(&path)?;
169 (schema.as_deref() != Some(store::FLEET_SCHEMA_KIND))
170 .then(|| format!("{}/{bare} ({})", root.origin, path.display()))
171 })
172 .collect();
173
174 let v2_candidates = store::v2_fleet_candidates(bare, workspace);
175 let v2_labels = || {
176 v2_candidates
177 .iter()
178 .map(|(scope, path)| format!("{}/{bare} ({})", scope.label(), path.display()))
179 };
180 if v2_candidates.len() > 1 || (!v2_candidates.is_empty() && !other_forms.is_empty()) {
181 return Err(NamedFleetError::AmbiguousFleet {
182 name: bare.to_string(),
183 origins: v2_labels().chain(other_forms).collect(),
184 });
185 }
186 match store::load_fleet(bare, workspace) {
187 Ok((fleet, scope, path)) => freeze_saved_fleet(&fleet, scope, &path, config),
188 Err(store::FleetStoreError::NotFound(_)) => FleetDocument::load_by_name(name, &roots),
189 Err(error) => Err(store_error(error)),
190 }
191 }
192
193 /// Freeze a saved v2 Fleet into an exact snapshot document.
194 ///
195 /// Every executable member leaves here with one concrete provider/model and
196 /// one concrete reasoning request: an explicit member pin wins, then the
197 /// Fleet's operator route, then the live session route from `config`. The
198 /// result is rendered as an exact document and parsed by the workflow crate's
199 /// own exact parser, so it passes the same validation as a hand-written exact
200 /// file, and the snapshot hash covers what was frozen. Editing the v2 file
201 /// afterwards changes only the next Workflow.
202 ///
203 /// Member `instructions` and `requires` are refused rather than dropped: the
204 /// exact snapshot has no field for either, and a Workflow that silently ran a
205 /// member without its instructions or capability requirement would not be the
206 /// saved Fleet.
207 fn freeze_saved_fleet(
208 fleet: &super::store::FleetFile,
209 scope: super::store::FleetScope,
210 path: &std::path::Path,
211 config: Option<&Config>,
212 ) -> Result<(FleetDocument, QualifiedFleetId), NamedFleetError> {
213 #[derive(serde::Serialize)]
214 struct FrozenFleet {
215 schema: &'static str,
216 schema_revision: u32,
217 name: String,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 description: Option<String>,
220 members: Vec<FrozenMember>,
221 }
222 #[derive(serde::Serialize)]
223 struct FrozenMember {
224 id: String,
225 role: String,
226 provider: String,
227 model: String,
228 reasoning: String,
229 }
230
231 let slug = fleet.file_slug();
232 let fail = |message: String| NamedFleetError::Parse {
233 path: path.display().to_string(),
234 message,
235 };
236 let operator = fleet.operator.as_ref();
237 // A saved Fleet stores reasoning in the session vocabulary (`xhigh`,
238 // `ultra`, `minimal`, ... — what an imported agent profile carries); the
239 // exact schema names tiers. Map through the same effort-to-tier table the
240 // preflight uses, keep an explicit `auto` as a Router request, and treat a
241 // blank value as absent (inherit), as the selected-Fleet path does.
242 let frozen_reasoning = |raw: Option<&str>| -> Result<Option<String>, String> {
243 let Some(value) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
244 return Ok(None);
245 };
246 let effort =
247 ReasoningEffort::parse_strict(value).map_err(|error| format!("reasoning: {error}"))?;
248 Ok(Some(
249 tier_of(effort)
250 .map_or("auto", ReasoningTier::as_str)
251 .to_string(),
252 ))
253 };
254 let session_route = config
255 .map(|config| {
256 config
257 .active_provider_identity()
258 .map(|identity| (identity.key.to_string(), config.default_model()))
259 })
260 .transpose()
261 .map_err(fail)?;
262 let session_reasoning = || {
263 let effort = config
264 .and_then(Config::reasoning_effort)
265 .map(ReasoningEffort::from_setting)
266 .unwrap_or_default();
267 // A session-level `auto` is per-turn adaptivity, not a Router
268 // request; a frozen member takes the concrete default tier instead.
269 tier_of(effort)
270 .unwrap_or(ReasoningTier::Max)
271 .as_str()
272 .to_string()
273 };
274
275 let mut unsupported = Vec::new();
276 let mut members = Vec::new();
277 for member in fleet.members.iter().filter(|member| !member.shortlist) {
278 let id = member.id.trim().to_string();
279 if member
280 .instructions
281 .as_deref()
282 .is_some_and(|text| !text.trim().is_empty())
283 {
284 unsupported.push(format!("`{id}` has instructions"));
285 }
286 if !member.requires.is_empty() {
287 unsupported.push(format!("`{id}` has requires"));
288 }
289 let (provider, model) = match (&member.provider, &member.model, operator, &session_route) {
290 (Some(provider), Some(model), _, _) => (provider.clone(), model.clone()),
291 (None, None, Some(operator), _) => (operator.provider.clone(), operator.model.clone()),
292 (None, None, None, Some((provider, model))) => (provider.clone(), model.clone()),
293 (None, None, None, None) => {
294 return Err(fail(format!(
295 "member `{id}` inherits the session route, but no session config is \
296 available to resolve it"
297 )));
298 }
299 _ => {
300 return Err(fail(format!(
301 "member `{id}` has a partial provider/model pin"
302 )));
303 }
304 };
305 let reasoning = match frozen_reasoning(member.reasoning.as_deref())
306 .map_err(|error| fail(format!("member `{id}` {error}")))?
307 {
308 Some(tier) => tier,
309 None => frozen_reasoning(operator.and_then(|operator| operator.reasoning.as_deref()))
310 .map_err(|error| fail(format!("operator {error}")))?
311 .unwrap_or_else(session_reasoning),
312 };
313 members.push(FrozenMember {
314 role: member.role_label().to_string(),
315 id,
316 provider,
317 model,
318 reasoning,
319 });
320 }
321 if !unsupported.is_empty() {
322 return Err(fail(format!(
323 "saved Fleet `{}` cannot run as a Workflow Fleet yet: {}. Workflow snapshots freeze \
324 each member's route and reasoning only; remove those fields or run the members \
325 with `agent`.",
326 fleet.name,
327 unsupported.join(", ")
328 )));
329 }
330
331 let frozen = FrozenFleet {
332 schema: codewhale_workflow::EXACT_FLEET_SCHEMA_KIND,
333 schema_revision: codewhale_workflow::EXACT_FLEET_SCHEMA_REVISION,
334 name: slug.clone(),
335 description: fleet.description.clone(),
336 members,
337 };
338 let text = toml::to_string(&frozen)
339 .map_err(|error| fail(format!("failed to freeze saved Fleet: {error}")))?;
340 let document = FleetDocument::from_frozen_saved_fleet(&text, path).map_err(|error| {
341 fail(format!(
342 "saved Fleet `{}` cannot run as a Workflow Fleet: {error}",
343 fleet.name
344 ))
345 })?;
346 Ok((
347 document,
348 QualifiedFleetId {
349 name: slug,
350 origin: scope.label().to_string(),
351 },
352 ))
353 }
354
355 // ── Preflight: freeze the route, and check it while freezing ─────────────────
356
357 /// Derive a route's real reasoning capability from the request shaping the
358 /// client actually performs, rather than from a hand-maintained claims table.
359 ///
360 /// The probe builds the request body this exact route would receive for every
361 /// tier and compares them. Two tiers that produce a byte-identical body are not
362 /// two provider-effective tiers, whatever the selector calls them — this is why
363 /// Z.AI's GLM routes come back as
364 /// [`ProviderReasoningControl::EnabledDisabled`] and why nothing here can claim
365 /// provider-native adaptive for a route whose body does not say so.
366 #[must_use]
367 pub(crate) fn reasoning_capability_for_route(
368 provider: ProviderKind,
369 base_url: &str,
370 wire_model: &str,
371 ) -> ReasoningCapability {
372 let body_for = |effort: ReasoningEffort| -> String {
373 let mut body = serde_json::json!({});
374 let value = effort.api_value_for_route(provider, base_url, wire_model);
375 crate::client::apply_reasoning_effort(&mut body, value, provider);
376 // `reasoning_split` is a transport concern the client sets for every
377 // tier; it carries no reasoning depth, so it must not make tiers look
378 // distinct or make a no-control route look controllable.
379 if let Some(object) = body.as_object_mut() {
380 object.remove("reasoning_split");
381 }
382 body.to_string()
383 };
384
385 let off = body_for(ReasoningEffort::Off);
386 let above_off: Vec<String> = [
387 ReasoningEffort::Low,
388 ReasoningEffort::Medium,
389 ReasoningEffort::High,
390 ReasoningEffort::Max,
391 ]
392 .into_iter()
393 .map(body_for)
394 .collect();
395
396 let empty = "{}";
397 let all_empty = off == empty && above_off.iter().all(|body| body == empty);
398
399 let mut distinct = above_off.clone();
400 distinct.sort();
401 distinct.dedup();
402
403 let control = if all_empty {
404 ProviderReasoningControl::None
405 } else if distinct.len() == 1 && distinct[0] == off && off.contains("adaptive") {
406 // Every tier — including off — produces the same adaptive body: the
407 // provider genuinely chooses its own depth. Source-backed, not assumed.
408 ProviderReasoningControl::NativeAdaptive
409 } else if distinct.len() > 1 {
410 ProviderReasoningControl::Tiers
411 } else {
412 ProviderReasoningControl::EnabledDisabled
413 };
414
415 // What each requested tier actually becomes on the wire, straight from the
416 // route normalizer that shapes the real request.
417 //
418 // This subsumes a min/max floor-and-ceiling and expresses what one cannot:
419 // most non-Codex routes coerce `low` and `medium` to `high` while leaving
420 // `off` alone (first-party DeepSeek routes are the documented exception —
421 // their wire carries a real `low`), and an always-thinking route raises
422 // `off` instead. Reporting a `low` a route silently sends as `high` is
423 // the invisible substitution receipts exist to prevent, so the map — not
424 // a clamp — is the authority.
425 let wire_tiers = [
426 ReasoningEffort::Off,
427 ReasoningEffort::Low,
428 ReasoningEffort::Medium,
429 ReasoningEffort::High,
430 ReasoningEffort::Max,
431 ]
432 .map(|effort| {
433 tier_of(effort.normalize_for_route(provider, base_url, wire_model))
434 .unwrap_or(ReasoningTier::Off)
435 });
436
437 ReasoningCapability {
438 control,
439 min_tier: None,
440 max_tier: None,
441 wire_tiers: None,
442 }
443 .with_wire_tiers(wire_tiers)
444 }
445
446 fn tier_of(effort: ReasoningEffort) -> Option<ReasoningTier> {
447 match effort {
448 ReasoningEffort::Off => Some(ReasoningTier::Off),
449 ReasoningEffort::Minimal => Some(ReasoningTier::Low),
450 ReasoningEffort::Low => Some(ReasoningTier::Low),
451 ReasoningEffort::Medium => Some(ReasoningTier::Medium),
452 ReasoningEffort::High => Some(ReasoningTier::High),
453 ReasoningEffort::XHigh => Some(ReasoningTier::Max),
454 ReasoningEffort::Ultra => Some(ReasoningTier::Max),
455 ReasoningEffort::Max => Some(ReasoningTier::Max),
456 ReasoningEffort::Auto => None,
457 }
458 }
459
460 /// The **provider-facing** reasoning value for one tier on one exact route.
461 ///
462 /// A tier label (`off`, `max`) is a selector concept; what a request may carry
463 /// is a provider concept, and the two are not the same string. OpenAI Codex
464 /// routes spell the top tier `xhigh` and cannot express `off` at all, so
465 /// placing a bare tier label on a Codex request either sends a value the
466 /// provider does not accept or silently sends nothing and takes the provider
467 /// default while the receipt claims the tier. Reading the value back out of the
468 /// same route normalizer the client uses is what keeps the request and the
469 /// receipt describing each other.
470 #[must_use]
471 pub(crate) fn route_reasoning_setting(
472 provider: ProviderKind,
473 base_url: &str,
474 wire_model: &str,
475 tier: ReasoningTier,
476 ) -> String {
477 effort_of(tier)
478 .as_setting_for_route(provider, base_url, wire_model)
479 .to_string()
480 }
481
482 fn effort_of(tier: ReasoningTier) -> ReasoningEffort {
483 match tier {
484 ReasoningTier::Off => ReasoningEffort::Off,
485 ReasoningTier::Low => ReasoningEffort::Low,
486 ReasoningTier::Medium => ReasoningEffort::Medium,
487 ReasoningTier::High => ReasoningEffort::High,
488 ReasoningTier::Max => ReasoningEffort::Max,
489 }
490 }
491
492 /// Preflight one exact route: resolve the provider, canonicalize the model,
493 /// identify the endpoint, decide credential readiness **from local config**,
494 /// and derive the reasoning capability.
495 ///
496 /// No provider is contacted. Everything here is a configuration lookup, which
497 /// is what makes it safe to run before the operator's gates have fired.
498 pub(crate) fn preflight_route(
499 member_id: &str,
500 provider: &str,
501 model: &str,
502 config: &Config,
503 ) -> Result<PreflightedRoute, PreflightError> {
504 let identity = config
505 .resolve_provider_identity(provider.trim())
506 .map_err(|detail| PreflightError::ProviderUnresolved {
507 member: member_id.to_string(),
508 provider: provider.to_string(),
509 detail,
510 })?;
511
512 // The canonical wire model, resolved once. The receipt and the child spawn
513 // both read this value, so they cannot disagree about what actually ran.
514 let wire_model = crate::config::requested_model_for_provider(identity.provider, model.trim())
515 .ok_or_else(|| PreflightError::ModelUnresolved {
516 member: member_id.to_string(),
517 provider: identity.key.to_string(),
518 model: model.to_string(),
519 detail: "not a known model for this provider".to_string(),
520 })?;
521 crate::config::validate_route(identity.provider, &wire_model).map_err(|detail| {
522 PreflightError::ModelUnresolved {
523 member: member_id.to_string(),
524 provider: identity.key.to_string(),
525 model: wire_model.clone(),
526 detail,
527 }
528 })?;
529
530 let mut scoped = config.clone();
531 scoped
532 .scope_to_provider_identity(&identity)
533 .map_err(|detail| PreflightError::ProviderUnresolved {
534 member: member_id.to_string(),
535 provider: provider.to_string(),
536 detail,
537 })?;
538 let base_url = scoped.active_route_base_url();
539
540 // Locally decided. A concrete loopback/self-hosted route is keyless by
541 // design, and that is a valid, first-class state — not a downgrade and
542 // not a missing credential. Ollama Cloud is hosted and falls through to
543 // the ordinary credential checks.
544 let credential =
545 if crate::config::provider_route_is_keyless_self_hosted(identity.provider, &base_url) {
546 CredentialReadiness::KeylessLocal
547 } else if crate::config::has_api_key_for(&scoped, &identity) {
548 CredentialReadiness::Configured
549 } else {
550 // The discriminant only. `Missing { detail }` names the provider table
551 // key, which for a custom route is the customer's own string.
552 codewhale_telemetry::session_counters()
553 .bump_error(codewhale_telemetry::ErrorCounter::AuthPreflightFailed);
554 CredentialReadiness::Missing {
555 detail: format!("no credential configured for `{}`", identity.key),
556 }
557 };
558
559 Ok(PreflightedRoute {
560 member_id: member_id.to_string(),
561 provider_id: identity.key.to_string(),
562 provider_config_id: identity
563 .migrated_legacy_ollama_cloud_route
564 .then(|| provider.trim().to_string()),
565 provider_kind: if identity.provider == ProviderKind::OllamaCloud {
566 identity.provider.as_str().to_string()
567 } else {
568 format!("{:?}", identity.provider).to_ascii_lowercase()
569 },
570 declared_model: model.trim().to_string(),
571 wire_model: wire_model.clone(),
572 endpoint: EndpointIdentity::from_base_url(&base_url),
573 credential,
574 capability: reasoning_capability_for_route(identity.provider, &base_url, &wire_model),
575 })
576 }
577
578 /// Build the client one worker route would actually run on, and throw it away.
579 ///
580 /// Preflight resolves a route from *configuration*; this proves the same route
581 /// can be turned into a working client — the step that fails on a malformed
582 /// base URL, an unusable auth mode, or a transport CodeWhale cannot construct.
583 /// Doing it at Workflow start, for every member, is what stops a Fleet from
584 /// paying for a Router decision and only then discovering that the worker it
585 /// decided for could never have been launched.
586 ///
587 /// The client is deliberately not retained: the spawn path builds the child's
588 /// own client from the member's roster profile, and keeping a second one here
589 /// would create two objects that could drift apart.
590 fn validate_route_client(route: &PreflightedRoute, config: &Config) -> Result<(), String> {
591 let mut scoped = config.clone();
592 let identity = config.resolve_provider_identity(route.provider_config_id())?;
593 scoped.scope_to_provider_identity(&identity)?;
594 crate::client::CodewhaleClient::new(&scoped)
595 .map(|_| ())
596 .map_err(|error| {
597 format!(
598 "member `{}` is pinned to provider `{}` (model `{}`), whose client could not be \
599 built on this machine: {error}",
600 route.member_id, route.provider_id, route.wire_model
601 )
602 })
603 }
604
605 // ── The Reasoning Router, as a service ──────────────────────────────────────
606
607 /// The seam a Reasoning Router call goes through. Implemented live against the
608 /// provider client, and by a fixture in tests so the whole reasoning path is
609 /// exercised without a network.
610 #[async_trait]
611 pub(crate) trait FleetRouterCaller: Send + Sync + std::fmt::Debug {
612 /// Return the router's raw text response for one worker task.
613 async fn decide(&self, input: &RouterCallInput) -> Result<String, String>;
614
615 /// The Router service's exact identity, for the receipt.
616 fn identity(&self) -> RouterIdentity;
617 }
618
619 /// A Reasoning Router bound to its own exact preflighted route.
620 #[derive(Clone)]
621 pub(crate) struct LiveFleetRouter {
622 client: crate::client::CodewhaleClient,
623 captured: CapturedReasoningRouter,
624 route: PreflightedRoute,
625 /// The Router route's provider kind and base URL, kept so the call's
626 /// reasoning value can be shaped by the *actual* configured route rather
627 /// than by a generic tier label. Never serialized — the base URL can carry
628 /// a credential and receipts are durable.
629 provider: ProviderKind,
630 base_url: String,
631 /// What the Router call is actually made at, plus the four-sided disclosure
632 /// for the receipt. Configured by the operator (`off` or `low`), normalized
633 /// only against what the Router's own route can express.
634 call: RouterCallPlan,
635 }
636
637 impl std::fmt::Debug for LiveFleetRouter {
638 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
639 f.debug_struct("LiveFleetRouter")
640 .field("router", &self.captured.qualified())
641 .field("provider", &self.route.provider_id)
642 .field("model", &self.route.wire_model)
643 .field("call_reasoning", &self.call.tier)
644 .field("client", &"<redacted>")
645 .finish()
646 }
647 }
648
649 impl LiveFleetRouter {
650 /// Resolve the Router service's exact configured route and build its client.
651 ///
652 /// A Router that cannot be resolved is an error here — at Workflow start,
653 /// before any worker is dispatched — not a silent downgrade to legacy
654 /// routing. Readiness is decided from local configuration; no live probe.
655 pub(crate) fn bind(
656 captured: &CapturedReasoningRouter,
657 config: &Config,
658 ) -> Result<Self, RouterBindError> {
659 let route = preflight_route(
660 &captured.id,
661 &captured.route.provider,
662 &captured.route.model,
663 config,
664 )
665 .map_err(|error| RouterBindError {
666 reason: error.to_string(),
667 })?;
668 route.require_ready().map_err(|error| RouterBindError {
669 reason: error.to_string(),
670 })?;
671
672 let identity = config
673 .resolve_provider_identity(route.provider_config_id())
674 .map_err(|detail| RouterBindError {
675 reason: format!(
676 "reasoning router provider `{}` did not resolve: {detail}",
677 route.provider_id
678 ),
679 })?;
680 let mut scoped = config.clone();
681 scoped
682 .scope_to_provider_identity(&identity)
683 .map_err(|reason| RouterBindError { reason })?;
684 let base_url = scoped.active_route_base_url();
685 let client =
686 crate::client::CodewhaleClient::new(&scoped).map_err(|error| RouterBindError {
687 reason: format!(
688 "reasoning router provider `{}` client could not be built: {error}",
689 route.provider_id
690 ),
691 })?;
692
693 let call = router_call_plan(captured.requested_call_reasoning, &route.capability);
694
695 Ok(Self {
696 client,
697 captured: captured.clone(),
698 route,
699 provider: identity.provider,
700 base_url,
701 call,
702 })
703 }
704
705 /// The preflighted Router route, for cross-provider disclosure.
706 #[must_use]
707 pub(crate) fn route(&self) -> &PreflightedRoute {
708 &self.route
709 }
710 }
711
712 #[derive(Debug, Clone, PartialEq, Eq)]
713 pub(crate) struct RouterBindError {
714 pub(crate) reason: String,
715 }
716
717 #[async_trait]
718 impl FleetRouterCaller for LiveFleetRouter {
719 fn identity(&self) -> RouterIdentity {
720 RouterIdentity::from_captured(
721 &self.captured,
722 Some(&self.route),
723 Some(self.call.disclosure.clone()),
724 )
725 }
726
727 async fn decide(&self, input: &RouterCallInput) -> Result<String, String> {
728 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt};
729
730 // The bounded, redacted summary is transmitted exactly once, in the
731 // user turn. The system prompt carries the contract and the frozen
732 // route, and no task content at all — sending it twice would double
733 // what leaves for this provider while the receipt counted one copy.
734 let request = MessageRequest {
735 model: self.route.wire_model.clone(),
736 messages: vec![Message {
737 role: Role::User,
738 content: vec![ContentBlock::Text {
739 text: router_user_message(input),
740 cache_control: None,
741 }],
742 }],
743 max_tokens: self
744 .client
745 .effective_max_output_tokens(&self.route.wire_model),
746 system: Some(SystemPrompt::Text(router_system_prompt(input))),
747 // A router receives no tools. Ever.
748 tools: None,
749 tool_choice: None,
750 metadata: None,
751 thinking: None,
752 // The operator-configured call tier remains authoritative. The
753 // normal route allowance above leaves room for its hidden
754 // reasoning before the small JSON answer is emitted.
755 reasoning_effort: Some(route_reasoning_setting(
756 self.provider,
757 &self.base_url,
758 &self.route.wire_model,
759 self.call.tier,
760 )),
761 stream: Some(false),
762 temperature: None,
763 top_p: None,
764 };
765
766 let response = self
767 .client
768 .create_message(request)
769 .await
770 .map_err(|error| error.to_string())?;
771 if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
772 return Err(format!(
773 "reasoning router response incomplete: provider stop reason `{}`",
774 codewhale_models::stop_reason_detail(response.stop_reason.as_deref())
775 ));
776 }
777 let text = response
778 .content
779 .into_iter()
780 .filter_map(|block| match block {
781 ContentBlock::Text { text, .. } => Some(text),
782 _ => None,
783 })
784 .collect::<Vec<_>>()
785 .join("");
786 if text.trim().is_empty() {
787 return Err("reasoning router returned an empty response".to_string());
788 }
789 Ok(text)
790 }
791 }
792
793 // ── The Workflow ───────────────────────────────────────────────────────────
794
795 /// An exact Fleet, frozen at Workflow start.
796 ///
797 /// The snapshot and the preflight are immutable for the life of the run:
798 /// editing `fleets/<name>.toml` afterwards changes only the next Workflow.
799 /// Durable runs and in-process spawns bind the same frozen member. The
800 /// in-process path projects only that member onto its existing profile binder;
801 /// it does not read or replace the currently selected Fleet.
802 #[derive(Clone)]
803 pub(crate) struct ExactFleetWorkflow {
804 snapshot: Arc<FleetSnapshot>,
805 preflight: Arc<RoutePreflight>,
806 router: Option<Arc<dyn FleetRouterCaller>>,
807 router_unavailable: Option<String>,
808 }
809
810 impl std::fmt::Debug for ExactFleetWorkflow {
811 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
812 f.debug_struct("ExactFleetWorkflow")
813 .field("fleet", &self.snapshot.fleet().qualified())
814 .field("members", &self.snapshot.members().len())
815 .field("router", &self.router.is_some())
816 .finish()
817 }
818 }
819
820 /// Whether `tool` is removed by a deny entry (exact, case-insensitive, or a
821 /// trailing-`*` prefix glob).
822 fn tool_denied_by(tool: &str, denied: &[String]) -> bool {
823 denied.iter().any(|entry| match entry.strip_suffix('*') {
824 Some(prefix) => tool
825 .to_ascii_lowercase()
826 .starts_with(&prefix.to_ascii_lowercase()),
827 None => entry.eq_ignore_ascii_case(tool),
828 })
829 }
830
831 /// Refuse a task whose explicitly requested tools do not all survive the
832 /// role ceiling and deny list (SHA-6734). The error names the requested
833 /// tools, what the role allows, and what was dropped, so the caller can fix
834 /// the request instead of launching a child that flounders without tools.
835 fn refuse_dropped_requested_tools(
836 member_id: &str,
837 member_role: &str,
838 requested: &[String],
839 ceiling: &ChildAuthority,
840 authority: &ChildAuthority,
841 ) -> Result<(), String> {
842 let mut requested: Vec<String> = requested.to_vec();
843 requested.sort();
844 requested.dedup();
845 if requested.is_empty() {
846 // An explicit empty list is a deliberate tool-free child.
847 return Ok(());
848 }
849 let survives = |tool: &String| {
850 authority
851 .allowed_tools
852 .as_ref()
853 .is_none_or(|allowed| allowed.contains(tool))
854 && !tool_denied_by(tool, &authority.disallowed_tools)
855 };
856 let dropped: Vec<&String> = requested.iter().filter(|tool| !survives(tool)).collect();
857 if dropped.is_empty() {
858 return Ok(());
859 }
860 let kept = requested.len() - dropped.len();
861 let role_allows = match ceiling.allowed_tools.as_ref() {
862 Some(allowed) if allowed.is_empty() => "no tools".to_string(),
863 Some(allowed) => format!("[{}]", allowed.join(", ")),
864 None if ceiling.disallowed_tools.is_empty() => "all inherited tools".to_string(),
865 None => format!(
866 "all inherited tools except [{}]",
867 ceiling.disallowed_tools.join(", ")
868 ),
869 };
870 let join = |tools: &[&String]| {
871 tools
872 .iter()
873 .map(|tool| tool.as_str())
874 .collect::<Vec<_>>()
875 .join(", ")
876 };
877 let requested_refs: Vec<&String> = requested.iter().collect();
878 let headline = if kept == 0 {
879 format!("Agent '{member_id}' (role {member_role}) would start with no tools")
880 } else {
881 format!("Agent '{member_id}' (role {member_role}) would lose requested tools")
882 };
883 Err(format!(
884 "{headline}: requested [{}], role allows {role_allows}, dropped [{}]. Request only tools \
885 the role allows, or pick a member whose role carries them.",
886 join(&requested_refs),
887 join(&dropped),
888 ))
889 }
890
891 /// One member, resolved and admitted — but **not yet routed**.
892 ///
893 /// This is the value the caller holds between admission and the Router call.
894 /// Producing it costs nothing: no provider is contacted, so a task that is
895 /// about to be rejected by a gate or blocked on capacity can be resolved
896 /// safely.
897 #[derive(Debug, Clone, PartialEq, Eq)]
898 pub(crate) struct ExactMemberBinding {
899 /// Canonical member id from the frozen snapshot.
900 pub(crate) member_id: String,
901 /// Semantic role — what gates, handoffs, and records use.
902 pub(crate) member_role: String,
903 /// The preflighted, frozen route.
904 pub(crate) route: PreflightedRoute,
905 /// Whether this member's reasoning comes from the Router.
906 pub(crate) requires_router: bool,
907 /// The clamped authority the child will actually run under.
908 pub(crate) authority: ChildAuthority,
909 /// The live session posture this binding was clamped against, kept so the
910 /// launch half can **recompute** the authority instead of trusting the copy
911 /// it was handed. A binding travels across an await point (gates, a
912 /// concurrency slot, a router call); recomputing is what makes a stale or
913 /// tampered authority detectable rather than merely improbable.
914 pub(crate) session: PermissionCeiling,
915 /// Source layer captured with the snapshot, never refreshed at spawn.
916 profile_origin: super::roster::ProfileOrigin,
917 task_allowed_tools: Option<Vec<String>>,
918 task_disallowed_tools: Vec<String>,
919 task_worktree_write: bool,
920 }
921
922 impl ExactMemberBinding {
923 /// Task options may narrow the Runtime/parent envelope, never replace the
924 /// frozen identity or grant authority. Preserve the narrowing for the
925 /// independent launch-time recomputation and its durable fingerprint.
926 pub(crate) fn narrow_for_task(
927 &mut self,
928 write_authority: Option<&str>,
929 allowed_tools: Option<&[String]>,
930 disallowed_tools: &[String],
931 max_depth: Option<u32>,
932 ) -> Result<(), String> {
933 match write_authority {
934 Some("read_only") => self.session.write = false,
935 Some("workspace_write" | "worktree_write") if !self.authority.ceiling.write => {
936 return Err(format!(
937 "member `{}` is read-only under its Runtime/parent ceiling; a task cannot request write authority via `write_authority`",
938 self.member_id,
939 ));
940 }
941 Some("workspace_write") if self.task_worktree_write => {
942 return Err("a task cannot remove its worktree isolation".to_string());
943 }
944 Some("worktree_write") => self.task_worktree_write = true,
945 Some("workspace_write") | None => {}
946 Some(other) => return Err(format!("invalid task `write_authority` value `{other}`")),
947 }
948 if let Some(depth) = max_depth {
949 self.session.delegation_depth = self.session.delegation_depth.min(depth);
950 }
951 if let Some(tools) = allowed_tools {
952 let mut tools = tools.to_vec();
953 if let Some(previous) = self.task_allowed_tools.as_ref() {
954 tools.retain(|tool| previous.contains(tool));
955 }
956 tools.sort();
957 tools.dedup();
958 self.task_allowed_tools = Some(tools);
959 }
960 self.task_disallowed_tools
961 .extend_from_slice(disallowed_tools);
962 self.task_disallowed_tools.sort();
963 self.task_disallowed_tools.dedup();
964 let authority = self.recompute_authority(&self.member_role);
965 // SHA-6734: narrowing an explicit request against the role ceiling
966 // used to be silent and could start a child with no tools at all.
967 // Every tool the caller asked for by name must survive, or the spawn
968 // is refused with what was asked, allowed, and dropped.
969 if let Some(requested) = allowed_tools {
970 let ceiling = ChildAuthority::from_runtime_role(&self.member_role, self.session);
971 refuse_dropped_requested_tools(
972 &self.member_id,
973 &self.member_role,
974 requested,
975 &ceiling,
976 &authority,
977 )?;
978 }
979 self.authority = authority;
980 Ok(())
981 }
982
983 fn recompute_authority(&self, role: &str) -> ChildAuthority {
984 let mut authority = ChildAuthority::from_runtime_role(role, self.session);
985 if let Some(tools) = self.task_allowed_tools.as_ref() {
986 let mut tools = tools.clone();
987 if let Some(ceiling) = authority.allowed_tools.as_ref() {
988 tools.retain(|tool| ceiling.contains(tool));
989 }
990 authority.allowed_tools = Some(tools);
991 }
992 if !self.task_disallowed_tools.is_empty() {
993 authority
994 .disallowed_tools
995 .extend(self.task_disallowed_tools.iter().cloned());
996 authority.disallowed_tools.sort();
997 authority.disallowed_tools.dedup();
998 }
999 if authority.ceiling.write && self.task_worktree_write {
1000 authority.write_authority = "worktree_write";
1001 }
1002 authority
1003 }
1004
1005 /// Project one preflighted member into the existing profile binder. The
1006 /// saved provider configuration key stays paired with the canonical wire
1007 /// model (including compatibility-migrated provider identities).
1008 pub(crate) fn spawn_profile(&self) -> super::profile::AgentProfile {
1009 super::profile::AgentProfile {
1010 native_preset: None,
1011 id: self.member_id.clone(),
1012 display_name: None,
1013 description: None,
1014 requires: Vec::new(),
1015 profile: codewhale_config::FleetProfile {
1016 slot: codewhale_config::FleetSlot::from_name(&self.member_role),
1017 role: codewhale_config::FleetRole {
1018 name: self.member_role.clone(),
1019 ..Default::default()
1020 },
1021 provider: Some(self.route.provider_config_id().to_string()),
1022 model: Some(self.route.wire_model.clone()),
1023 ..Default::default()
1024 },
1025 // Snapshot identity is recorded on the Workflow receipt; profile
1026 // application performs no source-file lookup.
1027 source: std::path::PathBuf::new(),
1028 origin: self.profile_origin,
1029 plugin_authority: None,
1030 }
1031 }
1032 }
1033
1034 /// What a launched exact member resolves to, after routing.
1035 #[derive(Debug, Clone, PartialEq, Eq)]
1036 pub(crate) struct ExactMemberLaunch {
1037 /// Canonical member id; also the roster profile id the spawn resolves.
1038 pub(crate) member_id: String,
1039 /// Semantic role, preserved for gates/handoffs/records.
1040 pub(crate) member_role: String,
1041 /// Frozen provider id.
1042 pub(crate) provider: String,
1043 /// Canonical wire model — the same string the receipt records.
1044 pub(crate) model: String,
1045 /// Concrete reasoning setting label for the spawn request.
1046 pub(crate) thinking: String,
1047 /// The full requested → effective story, for the receipt.
1048 pub(crate) reasoning: ResolvedReasoning,
1049 /// The clamped authority the child runs under.
1050 pub(crate) authority: ChildAuthority,
1051 /// The durable, visible receipt for this launch.
1052 pub(crate) receipt: FleetTaskReceipt,
1053 }
1054
1055 impl ExactFleetWorkflow {
1056 /// Capture a Workflow from a parsed exact Fleet document.
1057 ///
1058 /// Everything that can fail locally fails here, before any worker is
1059 /// dispatched: an unresolvable provider, an unknown model, a missing
1060 /// credential, an unresolvable Reasoning Router profile, or an `auto`
1061 /// member with no usable Router.
1062 pub(crate) fn capture(
1063 document: &FleetDocument,
1064 id: QualifiedFleetId,
1065 captured_at: impl Into<String>,
1066 config: Option<&Config>,
1067 search_roots: &[FleetSearchRoot],
1068 ) -> Result<Self, String> {
1069 let exact = document
1070 .exact()
1071 .ok_or_else(|| "this Fleet is not an exact Fleet".to_string())?;
1072
1073 // Resolve the attached Reasoning Router *reference* into the one
1074 // captured service both forms normalize onto.
1075 let captured_router = match exact.router_ref() {
1076 None => None,
1077 Some(FleetRouterRef::LegacyInline(_)) => captured_legacy_inline_router(exact),
1078 Some(FleetRouterRef::Profile { name }) => {
1079 let (profile, router_id) =
1080 ReasoningRouterProfile::load_by_name(&name, search_roots).map_err(|error| {
1081 format!(
1082 "exact Fleet `{}` references reasoning router `{name}`, which could \
1083 not be loaded: {error}",
1084 id.qualified()
1085 )
1086 })?;
1087 Some(CapturedReasoningRouter::from_profile(
1088 &profile,
1089 router_id.origin,
1090 ))
1091 }
1092 };
1093
1094 // Capture, then immediately verify the hash the receipt will vouch for.
1095 // `capture` computes it, so this can only fail if the value took a
1096 // detour through `Deserialize` — but that is exactly the case a receipt
1097 // must not certify, and checking here means no later caller has to
1098 // remember to.
1099 let snapshot = FleetSnapshot::capture(id, document, captured_at, captured_router.clone())
1100 .and_then(FleetSnapshot::into_verified)
1101 .map_err(|error| error.to_string())?;
1102
1103 // Preflight every worker route before anything else can happen.
1104 let (preflight, router) = Self::preflight_and_bind(&snapshot, captured_router, config)?;
1105
1106 let router_unavailable = match (snapshot.router(), &router) {
1107 (Some(_), None) => {
1108 Some("the Fleet's reasoning router could not be bound on this machine".to_string())
1109 }
1110 _ => None,
1111 };
1112
1113 let workflow = Self {
1114 snapshot: Arc::new(snapshot),
1115 preflight: Arc::new(preflight),
1116 router,
1117 router_unavailable,
1118 };
1119 workflow.reject_unusable_auto_members()?;
1120 Ok(workflow)
1121 }
1122
1123 /// Preflight every worker route and bind the Router, or fail the start.
1124 fn preflight_and_bind(
1125 snapshot: &FleetSnapshot,
1126 captured_router: Option<CapturedReasoningRouter>,
1127 config: Option<&Config>,
1128 ) -> Result<(RoutePreflight, Option<Arc<dyn FleetRouterCaller>>), String> {
1129 let Some(config) = config else {
1130 return Err(format!(
1131 "exact Fleet `{}` cannot start: no session config is available to preflight its \
1132 members' providers and models. An exact Fleet fails closed here rather than \
1133 dispatching a worker onto a route it never verified.",
1134 snapshot.fleet().qualified()
1135 ));
1136 };
1137
1138 let mut workers = Vec::with_capacity(snapshot.members().len());
1139 for member in snapshot.members() {
1140 let route = preflight_route(
1141 &member.id,
1142 &member.route.provider,
1143 &member.route.model,
1144 config,
1145 )
1146 .map_err(|error| {
1147 format!(
1148 "exact Fleet `{}` cannot start: {error}",
1149 snapshot.fleet().qualified()
1150 )
1151 })?;
1152 route.require_ready().map_err(|error| {
1153 format!(
1154 "exact Fleet `{}` cannot start: {error}",
1155 snapshot.fleet().qualified()
1156 )
1157 })?;
1158 workers.push(route);
1159 }
1160
1161 // Every worker client is constructed and validated **before** the
1162 // Router is bound, let alone called. A member whose client cannot be
1163 // built is a start-time failure; discovering it after a Router decision
1164 // means the operator paid for a routing request for a task that could
1165 // never have run.
1166 for route in &workers {
1167 validate_route_client(route, config).map_err(|error| {
1168 format!(
1169 "exact Fleet `{}` cannot start: {error}",
1170 snapshot.fleet().qualified()
1171 )
1172 })?;
1173 }
1174
1175 let mut router: Option<Arc<dyn FleetRouterCaller>> = None;
1176 let mut router_route = None;
1177 if let Some(captured) = &captured_router {
1178 match LiveFleetRouter::bind(captured, config) {
1179 Ok(live) => {
1180 router_route = Some(live.route().clone());
1181 router = Some(Arc::new(live));
1182 }
1183 Err(error) => {
1184 // Recorded rather than raised: a Fleet with no `auto`
1185 // member does not need its router to be usable, and
1186 // failing the whole Workflow for an unused service would
1187 // be the wrong trade.
1188 if snapshot.has_auto_member() {
1189 return Err(format!(
1190 "exact Fleet `{}` cannot start: member(s) {} request reasoning \
1191 `auto` but the Fleet's reasoning router is unusable ({}). Fix the \
1192 router profile or pin an explicit reasoning tier — exact Fleets \
1193 never fall back to legacy model routing or a local heuristic.",
1194 snapshot.fleet().qualified(),
1195 snapshot.auto_member_ids().join(", "),
1196 error.reason,
1197 ));
1198 }
1199 }
1200 }
1201 }
1202
1203 Ok((RoutePreflight::new(workers, router_route), router))
1204 }
1205
1206 /// Fail at Workflow start — not at task launch — when a member requests
1207 /// `auto` and the Fleet has no Router it can actually call.
1208 fn reject_unusable_auto_members(&self) -> Result<(), String> {
1209 if !self.snapshot.has_auto_member() || self.router.is_some() {
1210 return Ok(());
1211 }
1212 let reason = self
1213 .router_unavailable
1214 .clone()
1215 .unwrap_or_else(|| "this Fleet references no reasoning router".to_string());
1216 Err(format!(
1217 "exact Fleet `{}` cannot start: member(s) {} request reasoning `auto` but the Fleet's \
1218 reasoning router is unusable ({reason}). Attach a working reasoning router or pin an \
1219 explicit reasoning tier — exact Fleets never fall back to legacy model routing or a \
1220 local heuristic.",
1221 self.snapshot.fleet().qualified(),
1222 self.snapshot.auto_member_ids().join(", "),
1223 ))
1224 }
1225
1226 #[must_use]
1227 pub(crate) fn snapshot(&self) -> &Arc<FleetSnapshot> {
1228 &self.snapshot
1229 }
1230
1231 /// Human-readable roster listing for "unknown member" errors.
1232 #[must_use]
1233 pub(crate) fn member_names(&self) -> String {
1234 self.snapshot
1235 .members()
1236 .iter()
1237 .map(|member| {
1238 if member.role == member.id {
1239 member.id.clone()
1240 } else {
1241 format!("{} (role {})", member.id, member.role)
1242 }
1243 })
1244 .collect::<Vec<_>>()
1245 .join(", ")
1246 }
1247
1248 /// Resolve a task's `role`/`profile` to one admitted member, **without
1249 /// contacting any provider**.
1250 ///
1251 /// This is deliberately the cheap half of a launch. It runs before gate
1252 /// evaluation and before a concurrency slot is taken, so a task that is
1253 /// about to be rejected or queued costs nothing and discloses nothing.
1254 ///
1255 /// A task that names both a `profile` and a `role` which resolve to
1256 /// different members is **rejected**, not silently resolved by precedence:
1257 /// the two fields would then disagree about who ran, and the receipt could
1258 /// only record one of them.
1259 pub(crate) fn bind_member(
1260 &self,
1261 profile: Option<&str>,
1262 role: Option<&str>,
1263 session: PermissionCeiling,
1264 ) -> Result<ExactMemberBinding, String> {
1265 let fleet = self.snapshot.fleet().qualified();
1266 let profile = profile.map(str::trim).filter(|key| !key.is_empty());
1267 let role = role.map(str::trim).filter(|key| !key.is_empty());
1268
1269 let member = match (profile, role) {
1270 (None, None) => {
1271 return Err(format!(
1272 "Fleet `{fleet}` is an exact Fleet: every task must name a member via `role` \
1273 or `profile`. Members: {}",
1274 self.member_names()
1275 ));
1276 }
1277 (Some(profile), None) => self.lookup(profile)?,
1278 (None, Some(role)) => self.lookup(role)?,
1279 (Some(profile), Some(role)) => {
1280 let by_profile = self.lookup(profile)?;
1281 let by_role = self.lookup(role)?;
1282 if by_profile.id != by_role.id {
1283 return Err(format!(
1284 "Fleet `{fleet}`: task names profile `{profile}` (member `{}`) and role \
1285 `{role}` (member `{}`), which are different members. A task must name \
1286 one member; the two fields cannot disagree about who ran.",
1287 by_profile.id, by_role.id
1288 ));
1289 }
1290 by_profile
1291 }
1292 };
1293
1294 let route = self.preflight.worker(&member.id).ok_or_else(|| {
1295 format!(
1296 "Fleet `{fleet}`: member `{}` has no preflighted route",
1297 member.id
1298 )
1299 })?;
1300
1301 Ok(ExactMemberBinding {
1302 member_id: member.id.clone(),
1303 member_role: public_role_label(&member.role),
1304 route: route.clone(),
1305 requires_router: member.requested_reasoning.is_auto(),
1306 authority: ChildAuthority::from_runtime_role(&member.role, session),
1307 session,
1308 profile_origin: match self.snapshot.fleet().origin.as_str() {
1309 "workspace" => super::roster::ProfileOrigin::Workspace,
1310 "codewhale_home" => super::roster::ProfileOrigin::Personal,
1311 _ => super::roster::ProfileOrigin::Config,
1312 },
1313 task_allowed_tools: None,
1314 task_disallowed_tools: Vec::new(),
1315 task_worktree_write: false,
1316 })
1317 }
1318
1319 fn lookup(&self, key: &str) -> Result<&FleetSnapshotMember, String> {
1320 self.snapshot.member_by_id_or_role(key).ok_or_else(|| {
1321 format!(
1322 "unknown exact Fleet member `{key}` in `{}`. Members: {}",
1323 self.snapshot.fleet().qualified(),
1324 self.member_names()
1325 )
1326 })
1327 }
1328
1329 /// Finish an **already admitted** binding: decide only how hard the already
1330 /// frozen model thinks, then build the receipt.
1331 ///
1332 /// This is the half that can cost money. Calling it means the task has
1333 /// already passed its gates and holds a concurrency slot.
1334 pub(crate) async fn route_admitted_task(
1335 &self,
1336 binding: &ExactMemberBinding,
1337 task_summary: &str,
1338 ) -> Result<ExactMemberLaunch, String> {
1339 // The receipt built at the end of this function stamps
1340 // `snapshot.content_hash()` as evidence that this launch matched a saved
1341 // definition. Verify the hash actually describes the snapshot *before*
1342 // spending a router call or emitting that claim — an unverified hash is
1343 // not weaker evidence, it is a false receipt.
1344 self.snapshot
1345 .verify_content_hash()
1346 .map_err(|error| error.to_string())?;
1347
1348 let member = self.snapshot.member(&binding.member_id).ok_or_else(|| {
1349 format!(
1350 "Fleet `{}`: member `{}` vanished between admission and launch",
1351 self.snapshot.fleet().qualified(),
1352 binding.member_id
1353 )
1354 })?;
1355
1356 // Recompute authority from Runtime's role policy and the live-parent
1357 // posture this binding was admitted against, and require it to be
1358 // *identical* to the one the binding carries. The snapshot supplies
1359 // identity only; legacy internal `FleetProfilePermissions` input is never
1360 // consulted.
1361 //
1362 // A binding crosses gates, a concurrency wait, and (for `auto` members)
1363 // a router call before it gets here, so "the authority I was handed" and
1364 // "the authority this member actually has" are two different claims. The
1365 // launch below is the value the spawn path consumes, so it must be the
1366 // recomputed one; the equality check is what turns a divergence into a
1367 // refused launch instead of a silently widened child.
1368 let authority = binding.recompute_authority(&member.role);
1369 if authority != binding.authority {
1370 return Err(format!(
1371 "Fleet `{}`: member `{}` resolved a different permission envelope at launch than \
1372 at admission, so the launch is refused. admitted={} launched={}",
1373 self.snapshot.fleet().qualified(),
1374 binding.member_id,
1375 binding.authority.fingerprint(),
1376 authority.fingerprint(),
1377 ));
1378 }
1379
1380 // The route is already frozen and preflighted. Nothing below may move
1381 // it — not a task option, not the Router.
1382 let frozen = binding.route.frozen();
1383 let capability = binding.route.capability;
1384
1385 let availability = self.router_availability();
1386 let mut router_identity = None;
1387 let mut routing_summary: Option<RoutingDisclosure> = None;
1388 let decision = if binding.requires_router {
1389 let router = self.router.as_ref().ok_or_else(|| {
1390 format!(
1391 "member `{}` requests reasoning `auto` but Fleet `{}` has no usable reasoning \
1392 router",
1393 binding.member_id,
1394 self.snapshot.fleet().qualified()
1395 )
1396 })?;
1397 let cross_provider = self.preflight.crosses_providers(&binding.member_id);
1398 let payload = bounded_routing_payload(task_summary).with_cross_provider(cross_provider);
1399 // What actually leaves for the router's provider, recorded so the
1400 // receipt discloses it — counts and hash only, never the text.
1401 routing_summary = Some(payload.disclosure().clone());
1402 router_identity = Some(router.identity());
1403 let input = RouterCallInput {
1404 fleet: self.snapshot.fleet().qualified(),
1405 member_id: binding.member_id.clone(),
1406 frozen: frozen.clone(),
1407 payload,
1408 };
1409 let raw = router.decide(&input).await.map_err(|error| {
1410 format!(
1411 "reasoning router call failed for member `{}`: {error}",
1412 binding.member_id
1413 )
1414 })?;
1415 Some(parse_router_decision(&raw).map_err(|error| {
1416 format!(
1417 "reasoning router returned an unusable decision for member `{}`: {error}",
1418 binding.member_id
1419 )
1420 })?)
1421 } else {
1422 None
1423 };
1424
1425 let reasoning = resolve_exact_member_reasoning(
1426 &binding.member_id,
1427 &frozen,
1428 member.requested_reasoning,
1429 &capability,
1430 &availability,
1431 decision.as_ref(),
1432 router_identity.as_ref(),
1433 )
1434 .map_err(|error| error.to_string())?;
1435
1436 // Every exact launch carries a concrete tier. `auto` is resolved by the
1437 // router above and the literal sentinel never leaves this function.
1438 //
1439 // `NativeAdaptive` is no longer reachable here: removing the bypass
1440 // (so `auto` always asks the router) also removed the one path that
1441 // produced it. It used to be launched as `off`, which mislabelled the
1442 // request — a route choosing its own depth is not a route with thinking
1443 // disabled. Rather than re-introduce that lie, this fails loudly if the
1444 // variant ever comes back.
1445 let thinking = match reasoning.effective() {
1446 EffectiveReasoning::Tier(tier) => effort_of(tier).as_setting().to_string(),
1447 EffectiveReasoning::NativeAdaptive => {
1448 return Err(format!(
1449 "member `{}` resolved to provider-native adaptive reasoning, which an exact \
1450 Fleet launch cannot place on a request. Pin an explicit reasoning tier.",
1451 binding.member_id
1452 ));
1453 }
1454 };
1455
1456 // The durable receipt. Built here, at the one place that knows every
1457 // side of the decision, so no consumer has to re-derive it.
1458 let receipt = FleetTaskReceipt::new(
1459 self.snapshot.fleet().qualified(),
1460 self.snapshot.schema_kind(),
1461 self.snapshot.schema_revision(),
1462 self.snapshot.content_hash(),
1463 binding.member_id.clone(),
1464 binding.member_role.clone(),
1465 &binding.route,
1466 &reasoning,
1467 routing_summary,
1468 binding.authority.ceiling.network_tool,
1469 )
1470 // The fingerprint of the envelope this launch installs, carried on the
1471 // durable receipt so the spawn boundary has something to check against
1472 // rather than a sentinel it can only assume.
1473 .with_authority_fingerprint(authority.fingerprint())
1474 // Semantic role and runtime posture stay two separate facts all the way
1475 // onto the durable receipt: `member_role` is what the operator named
1476 // and what gates key on, `posture_role` is the Runtime baseline role.
1477 // The fingerprint above records the effective parent-narrowed surface.
1478 .with_posture_role(binding.authority.posture_role);
1479
1480 Ok(ExactMemberLaunch {
1481 member_id: binding.member_id.clone(),
1482 member_role: binding.member_role.clone(),
1483 provider: frozen.provider,
1484 model: frozen.model,
1485 thinking,
1486 reasoning,
1487 authority,
1488 receipt,
1489 })
1490 }
1491
1492 fn router_availability(&self) -> RouterAvailability {
1493 match (&self.router, &self.router_unavailable) {
1494 (Some(_), _) => RouterAvailability::Ready,
1495 (None, Some(reason)) => RouterAvailability::Unavailable {
1496 reason: reason.clone(),
1497 },
1498 (None, None) => RouterAvailability::Absent,
1499 }
1500 }
1501 }
1502
1503 // ── Test seams ──────────────────────────────────────────────────────────────
1504
1505 /// A Router that answers with a fixed fixture string, recording what it saw.
1506 ///
1507 /// Test-only: it is how the exact-Fleet reasoning path is exercised end to end
1508 /// without a provider call, and how "the router was never called" is asserted.
1509 #[cfg(test)]
1510 #[derive(Debug)]
1511 pub(crate) struct StaticFleetRouter {
1512 response: String,
1513 identity: RouterIdentity,
1514 pub(crate) seen: std::sync::Mutex<Vec<RouterCallInput>>,
1515 }
1516
1517 #[cfg(test)]
1518 impl StaticFleetRouter {
1519 pub(crate) fn new(response: impl Into<String>) -> Arc<Self> {
1520 Arc::new(Self {
1521 response: response.into(),
1522 identity: RouterIdentity {
1523 id: "luna-low".to_string(),
1524 origin: "workspace".to_string(),
1525 service_kind: codewhale_workflow::REASONING_ROUTER_SERVICE_KIND.to_string(),
1526 legacy_inline: false,
1527 provider: "openai".to_string(),
1528 model: "gpt-5.6-luna".to_string(),
1529 endpoint: Some(EndpointIdentity::from_base_url("https://api.openai.com/v1")),
1530 call: Some(
1531 router_call_plan(
1532 codewhale_workflow::RouterCallReasoning::Low,
1533 &ReasoningCapability::tiered(),
1534 )
1535 .disclosure,
1536 ),
1537 },
1538 seen: std::sync::Mutex::new(Vec::new()),
1539 })
1540 }
1541
1542 /// How many router calls were made. Zero is the assertion that matters for
1543 /// manual reasoning and for rejected/blocked tasks.
1544 pub(crate) fn call_count(&self) -> usize {
1545 self.seen.lock().expect("router log").len()
1546 }
1547 }
1548
1549 #[cfg(test)]
1550 #[async_trait]
1551 impl FleetRouterCaller for StaticFleetRouter {
1552 fn identity(&self) -> RouterIdentity {
1553 self.identity.clone()
1554 }
1555
1556 async fn decide(&self, input: &RouterCallInput) -> Result<String, String> {
1557 self.seen.lock().expect("router log").push(input.clone());
1558 Ok(self.response.clone())
1559 }
1560 }
1561
1562 #[cfg(test)]
1563 impl ExactFleetWorkflow {
1564 /// Build a Workflow with an injected Router and a supplied capability,
1565 /// skipping provider binding so the reasoning path runs with no network and
1566 /// no configured provider.
1567 /// Takes the concrete fixture type rather than `Option<Arc<dyn ...>>`:
1568 /// `Option` does not coerce its payload, so the unsizing is done once here
1569 /// instead of at every call site.
1570 pub(crate) fn for_tests(
1571 document: &FleetDocument,
1572 id: QualifiedFleetId,
1573 router: Option<Arc<StaticFleetRouter>>,
1574 ) -> Self {
1575 Self::for_tests_with_capability(document, id, router, ReasoningCapability::tiered())
1576 }
1577
1578 pub(crate) fn for_tests_with_capability(
1579 document: &FleetDocument,
1580 id: QualifiedFleetId,
1581 router: Option<Arc<StaticFleetRouter>>,
1582 capability: ReasoningCapability,
1583 ) -> Self {
1584 let exact = document.exact().expect("exact Fleet");
1585 let captured = captured_legacy_inline_router(exact).or_else(|| {
1586 exact.reasoning_router.as_ref().map(|name| {
1587 CapturedReasoningRouter::from_profile(
1588 &ReasoningRouterProfile::parse(&format!(
1589 "name = \"{name}\"\nschema = \"reasoning_router\"\nprovider = \
1590 \"openai\"\nmodel = \"gpt-5.6-luna\"\ncall_reasoning = \"low\"\n"
1591 ))
1592 .expect("router profile"),
1593 "workspace",
1594 )
1595 })
1596 });
1597 let snapshot =
1598 FleetSnapshot::capture(id, document, "2026-07-26T00:00:00Z", captured.clone())
1599 .expect("valid roster");
1600
1601 let workers = snapshot
1602 .members()
1603 .iter()
1604 .map(|member| {
1605 test_route(
1606 &member.id,
1607 &member.route.provider,
1608 &member.route.model,
1609 capability,
1610 )
1611 })
1612 .collect::<Vec<_>>();
1613 let router_route = captured.as_ref().map(|captured| {
1614 test_route(
1615 "router",
1616 &captured.route.provider,
1617 &captured.route.model,
1618 capability,
1619 )
1620 });
1621 let preflight = RoutePreflight::new(workers, router_route);
1622
1623 Self {
1624 snapshot: Arc::new(snapshot),
1625 preflight: Arc::new(preflight),
1626 router: router.map(|router| {
1627 let router: Arc<dyn FleetRouterCaller> = router;
1628 router
1629 }),
1630 router_unavailable: None,
1631 }
1632 }
1633
1634 /// A Workflow whose Router failed to bind locally — the shape
1635 /// [`Self::capture`] produces when a Router's provider has no credentials
1636 /// configured on this machine. No network is involved either way.
1637 pub(crate) fn for_tests_with_unavailable_router(
1638 document: &FleetDocument,
1639 id: QualifiedFleetId,
1640 reason: &str,
1641 ) -> Result<Self, String> {
1642 let mut workflow = Self::for_tests(document, id, None);
1643 workflow.router_unavailable = Some(reason.to_string());
1644 workflow.reject_unusable_auto_members()?;
1645 Ok(workflow)
1646 }
1647 }
1648
1649 #[cfg(test)]
1650 fn test_route(
1651 member: &str,
1652 provider: &str,
1653 model: &str,
1654 capability: ReasoningCapability,
1655 ) -> PreflightedRoute {
1656 PreflightedRoute {
1657 member_id: member.to_string(),
1658 provider_id: provider.to_string(),
1659 provider_config_id: None,
1660 provider_kind: provider.to_string(),
1661 declared_model: model.to_string(),
1662 wire_model: model.to_string(),
1663 endpoint: EndpointIdentity::from_base_url("https://api.example.test/v1"),
1664 credential: CredentialReadiness::Configured,
1665 capability,
1666 }
1667 }
1668
1669 #[cfg(test)]
1670 mod shell_ceiling_tests {
1671 use super::*;
1672
1673 fn ceiling(write: bool, shell: ShellCeiling) -> PermissionCeiling {
1674 PermissionCeiling {
1675 write,
1676 network_tool: false,
1677 shell,
1678 delegation_depth: 0,
1679 tools: true,
1680 }
1681 }
1682
1683 fn session() -> PermissionCeiling {
1684 ceiling(true, ShellCeiling::Full)
1685 }
1686
1687 fn denies_raw_shell(authority: &ChildAuthority) -> bool {
1688 authority
1689 .disallowed_tools
1690 .iter()
1691 .any(|rule| rule == RAW_SHELL_SENTINEL)
1692 }
1693
1694 /// The `analyst` preset grants no shell. The envelope reads its shell bit
1695 /// back off the deny list, so the denial has to actually be installed —
1696 /// otherwise a shell-less ceiling reaches dispatch claiming full shell
1697 /// authority and can start a verification process.
1698 #[test]
1699 fn a_shell_less_ceiling_installs_the_raw_shell_denial() {
1700 for shell in [ShellCeiling::None, ShellCeiling::ReadOnly] {
1701 let authority = ChildAuthority::clamp(ceiling(false, shell), session());
1702 assert!(
1703 denies_raw_shell(&authority),
1704 "{shell:?} must deny raw shell"
1705 );
1706 }
1707 }
1708
1709 /// The gap this repair closed: a write-capable member inside a session with
1710 /// no shell authority clamps to `write = true, shell = none`. Keying the
1711 /// denial on `write` alone left that combination with no denial installed —
1712 /// and therefore with an envelope that claimed shell authority the ceiling
1713 /// had refused.
1714 #[test]
1715 fn a_write_capable_member_clamped_to_no_shell_still_loses_raw_shell() {
1716 let authority = ChildAuthority::clamp(
1717 ceiling(true, ShellCeiling::Full),
1718 ceiling(true, ShellCeiling::None),
1719 );
1720
1721 assert_eq!(authority.ceiling.shell, ShellCeiling::None);
1722 assert!(authority.ceiling.write, "the write half is unchanged");
1723 assert!(denies_raw_shell(&authority));
1724 }
1725
1726 /// Prior behavior preserved: a `verifier`/`tester` ceiling
1727 /// (`write = false, shell = "full"`) still loses raw shell, and a fully
1728 /// write-capable member still keeps it.
1729 #[test]
1730 fn the_existing_verifier_and_full_ceilings_are_unchanged() {
1731 let verifier = ChildAuthority::clamp(ceiling(false, ShellCeiling::Full), session());
1732 assert!(denies_raw_shell(&verifier));
1733 assert_eq!(verifier.posture_role, "test");
1734
1735 let full = ChildAuthority::clamp(ceiling(true, ShellCeiling::Full), session());
1736 assert!(!denies_raw_shell(&full));
1737 assert_eq!(full.posture_role, "implement");
1738 }
1739
1740 #[test]
1741 fn bounded_inspection_role_keeps_only_classifier_bounded_bash() {
1742 for role in ["scout", "reviewer", "planner"] {
1743 let authority = ChildAuthority::from_runtime_role(role, session());
1744 assert!(
1745 !authority
1746 .disallowed_tools
1747 .iter()
1748 .any(|name| name.eq_ignore_ascii_case("Bash")),
1749 "{role} keeps canonical Bash for per-input classification"
1750 );
1751 for denied in [
1752 "exec_shell",
1753 "task_shell_start",
1754 "task_shell_wait",
1755 "terminal/*",
1756 "write_file",
1757 "apply_patch",
1758 ] {
1759 assert!(
1760 authority.disallowed_tools.iter().any(|name| name == denied),
1761 "{role} must still deny {denied}: {:?}",
1762 authority.disallowed_tools
1763 );
1764 }
1765 }
1766
1767 for role in ["consultant", "verifier"] {
1768 let authority = ChildAuthority::from_runtime_role(role, session());
1769 assert!(
1770 authority
1771 .disallowed_tools
1772 .iter()
1773 .any(|name| name.eq_ignore_ascii_case("Bash")),
1774 "{role} must not gain the read-only inspection exception"
1775 );
1776 }
1777
1778 let parent_shell_off =
1779 ChildAuthority::from_runtime_role("scout", ceiling(true, ShellCeiling::None));
1780 assert!(
1781 parent_shell_off
1782 .disallowed_tools
1783 .iter()
1784 .any(|name| name.eq_ignore_ascii_case("Bash")),
1785 "a named Scout may not turn a parent shell-off ceiling into ReadOnly"
1786 );
1787 let planner_parent_shell_off =
1788 ChildAuthority::from_runtime_role("planner", ceiling(true, ShellCeiling::None));
1789 assert!(
1790 planner_parent_shell_off
1791 .disallowed_tools
1792 .iter()
1793 .any(|name| name.eq_ignore_ascii_case("Bash")),
1794 "a named planner may not turn a parent shell-off ceiling into ReadOnly"
1795 );
1796 assert_eq!(planner_parent_shell_off.posture_role, "planner");
1797 assert_eq!(
1798 session_shell_ceiling(crate::worker_profile::ShellPolicy::Full, false),
1799 ShellCeiling::None
1800 );
1801 }
1802
1803 /// #5426 acceptance 2, made mechanical: delegation moves work, never
1804 /// authority. A read-only scout's own runtime posture is the "session"
1805 /// its children clamp against, so a Runtime `builder` dispatched from a
1806 /// read-only parent lands read-only — raw shell gone and mutating tools
1807 /// denied — while the Runtime posture remains separately identified and delegation stays
1808 /// available (the depth budget is the parent's, not zero). The escape
1809 /// hatch is work capacity, never a wider envelope.
1810 #[test]
1811 fn a_read_only_parents_delegation_never_widens_authority() {
1812 // The scout's live runtime posture, expressed as the session ceiling
1813 // a child clamps against: no writes, read-only shell, network kept,
1814 // one level of delegation budget left.
1815 let scout_runtime = PermissionCeiling {
1816 write: false,
1817 network_tool: true,
1818 shell: ShellCeiling::ReadOnly,
1819 delegation_depth: 1,
1820 tools: true,
1821 };
1822 let authority = ChildAuthority::from_runtime_role("builder", scout_runtime);
1823
1824 // Authority does not widen through delegation: the child is read-only.
1825 assert!(!authority.ceiling.write);
1826 assert_eq!(authority.ceiling.shell, ShellCeiling::ReadOnly);
1827 assert_eq!(authority.write_authority, "read_only");
1828 assert_eq!(authority.posture_role, "implement");
1829 assert!(denies_raw_shell(&authority));
1830 for mutating in ["write_file", "apply_patch"] {
1831 assert!(
1832 authority
1833 .disallowed_tools
1834 .iter()
1835 .any(|rule| rule == mutating),
1836 "{mutating} must stay denied for a scout-delegated builder: {:?}",
1837 authority.disallowed_tools
1838 );
1839 }
1840
1841 // The escape hatch itself stays open: delegation is still possible
1842 // (the parent's budget is intact). But it is useless for shell:
1843 // canonical Bash is denied to a delegated child (it is not a bounded
1844 // inspection role), so a scout can never obtain bash by spawning —
1845 // the scout's own bounded read-only Bash from #5428 is the only shell
1846 // path a read-only parent has.
1847 assert_eq!(authority.max_depth, 1);
1848 assert!(
1849 authority
1850 .disallowed_tools
1851 .iter()
1852 .any(|name| name.eq_ignore_ascii_case("Bash")),
1853 "a scout-delegated child must not gain canonical Bash: {:?}",
1854 authority.disallowed_tools
1855 );
1856 }
1857
1858 /// The deny list feeds the fingerprint, so a ceiling that now denies more
1859 /// must fingerprint differently from one that does not. Two postures that
1860 /// install different surfaces may never share a fingerprint.
1861 #[test]
1862 fn the_shell_denial_is_visible_in_the_fingerprint() {
1863 let no_shell = ChildAuthority::clamp(ceiling(false, ShellCeiling::None), session());
1864 let full = ChildAuthority::clamp(ceiling(true, ShellCeiling::Full), session());
1865
1866 assert_ne!(no_shell.fingerprint(), full.fingerprint());
1867 assert!(no_shell.fingerprint().contains("shell=none"));
1868 }
1869
1870 /// Every rule the shell clamp installs is a *posture* denial, so a
1871 /// grandchild spawned with `inherit_disallowed_tools: false` cannot drop it.
1872 #[test]
1873 fn the_installed_shell_denials_are_posture_denials() {
1874 let authority = ChildAuthority::clamp(ceiling(false, ShellCeiling::None), session());
1875 for rule in &authority.disallowed_tools {
1876 assert!(is_posture_denial(rule), "{rule} must be a posture denial");
1877 }
1878 }
1879 }
1880
1881 #[cfg(test)]
1882 mod tests {
1883 use super::*;
1884 use codewhale_workflow::{
1885 EffectiveReasoningSource, ProviderEffectiveReasoning, RequestedReasoning,
1886 };
1887
1888 /// A Fleet that references a saved, reusable Reasoning Router service.
1889 const GLM_FLEET: &str = r#"
1890 name = "glm-pair"
1891 schema = "exact"
1892 reasoning_router = "luna-low"
1893
1894 [[members]]
1895 id = "implementer"
1896 role = "builder"
1897 provider = "zai"
1898 model = "glm-5"
1899 reasoning = "auto"
1900 permissions = "read_write"
1901
1902 [[members]]
1903 id = "auditor"
1904 role = "reviewer"
1905 provider = "zai"
1906 model = "glm-5"
1907 reasoning = "high"
1908 permissions = "read_only"
1909 "#;
1910
1911 fn id() -> QualifiedFleetId {
1912 QualifiedFleetId {
1913 name: "glm-pair".to_string(),
1914 origin: "workspace".to_string(),
1915 }
1916 }
1917
1918 fn full_session() -> PermissionCeiling {
1919 PermissionCeiling {
1920 write: true,
1921 network_tool: true,
1922 shell: ShellCeiling::Full,
1923 delegation_depth: codewhale_config::DEFAULT_SPAWN_DEPTH,
1924 tools: true,
1925 }
1926 }
1927
1928 /// Takes the concrete fixture type: `Option` does not coerce its payload,
1929 /// so the unsizing to `Arc<dyn FleetRouterCaller>` is spelled out here once
1930 /// rather than at every call site.
1931 fn workflow_with(router: Option<Arc<StaticFleetRouter>>, text: &str) -> ExactFleetWorkflow {
1932 let document = FleetDocument::parse(text).expect("parse");
1933 ExactFleetWorkflow::for_tests(&document, id(), router)
1934 }
1935
1936 #[tokio::test]
1937 async fn an_auto_member_takes_a_reasoning_only_router_decision_on_a_frozen_route() {
1938 let router = StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
1939 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
1940
1941 let binding = workflow
1942 .bind_member(None, Some("builder"), full_session())
1943 .expect("role resolves");
1944 assert_eq!(
1945 router.call_count(),
1946 0,
1947 "binding a member must not cost a router call"
1948 );
1949
1950 let launch = workflow
1951 .route_admitted_task(&binding, "refactor three crates")
1952 .await
1953 .expect("auto resolves through the router");
1954
1955 // The route did not move.
1956 assert_eq!(launch.provider, "zai");
1957 assert_eq!(launch.model, "glm-5");
1958 assert_eq!(launch.thinking, "max");
1959 assert_eq!(launch.member_id, "implementer");
1960 assert_eq!(launch.member_role, "implement");
1961 assert_eq!(launch.reasoning.requested(), RequestedReasoning::Auto);
1962 assert_eq!(
1963 launch.reasoning.source(),
1964 EffectiveReasoningSource::FleetRouter
1965 );
1966
1967 // The router saw the frozen route as context, never as a question, and
1968 // received the bounded payload rather than the raw task.
1969 let seen = router.seen.lock().expect("log");
1970 assert_eq!(seen.len(), 1);
1971 assert_eq!(seen[0].frozen.model, "glm-5");
1972 assert_eq!(seen[0].member_id, "implementer");
1973 assert_eq!(seen[0].payload.text(), "refactor three crates");
1974 }
1975
1976 /// The semantic role must survive onto the launch and the receipt: a gate
1977 /// or handoff keyed on `builder` has to still see `builder` even though the
1978 /// roster resolves the distinct profile id `implementer`.
1979 #[tokio::test]
1980 async fn the_semantic_role_survives_while_the_id_addresses_the_roster() {
1981 let workflow = workflow_with(
1982 Some(StaticFleetRouter::new(r#"{"reasoning":"low"}"#)),
1983 GLM_FLEET,
1984 );
1985
1986 let binding = workflow
1987 .bind_member(None, Some("reviewer"), full_session())
1988 .expect("role lookup");
1989 assert_eq!(binding.member_id, "auditor");
1990 assert_eq!(binding.member_role, "reviewer");
1991
1992 let launch = workflow
1993 .route_admitted_task(&binding, "read the diff")
1994 .await
1995 .expect("launch");
1996 assert_eq!(launch.receipt.member_id, "auditor");
1997 assert_eq!(
1998 launch.receipt.member_role, "reviewer",
1999 "the receipt records the semantic role, not the profile id"
2000 );
2001
2002 // The snapshot is addressed by id; the role is the semantic label.
2003 let member = workflow
2004 .snapshot()
2005 .member("auditor")
2006 .expect("snapshot entry");
2007 assert_eq!(member.role, "reviewer");
2008 }
2009
2010 /// A task that names a profile and a role belonging to different members is
2011 /// rejected — the two fields cannot disagree about who ran.
2012 #[test]
2013 fn a_conflicting_task_role_and_profile_is_rejected() {
2014 let workflow = workflow_with(None, GLM_FLEET);
2015
2016 let err = workflow
2017 .bind_member(Some("implementer"), Some("reviewer"), full_session())
2018 .expect_err("conflicting identity");
2019 assert!(err.contains("different members"), "{err}");
2020 assert!(err.contains("implementer"), "{err}");
2021 assert!(err.contains("auditor"), "{err}");
2022
2023 // Agreeing fields are fine: id plus that member's own role.
2024 let binding = workflow
2025 .bind_member(Some("implementer"), Some("builder"), full_session())
2026 .expect("agreeing identity");
2027 assert_eq!(binding.member_id, "implementer");
2028 }
2029
2030 /// Manual reasoning uses no Router at all — not a call whose answer is
2031 /// discarded, but zero calls.
2032 #[tokio::test]
2033 async fn an_explicit_tier_member_never_calls_the_router() {
2034 let router = StaticFleetRouter::new(r#"{"reasoning":"off"}"#);
2035 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
2036
2037 let binding = workflow
2038 .bind_member(Some("auditor"), None, full_session())
2039 .expect("bind");
2040 assert!(!binding.requires_router);
2041
2042 let launch = workflow
2043 .route_admitted_task(&binding, "read the diff")
2044 .await
2045 .expect("explicit tier");
2046
2047 assert_eq!(launch.thinking, "high");
2048 assert_eq!(
2049 launch.reasoning.source(),
2050 EffectiveReasoningSource::MemberExplicit
2051 );
2052 assert_eq!(
2053 router.call_count(),
2054 0,
2055 "an explicit tier must not spend a router call"
2056 );
2057 assert!(launch.receipt.router.is_none());
2058 assert!(launch.receipt.routing_summary.is_none());
2059 assert!(!launch.receipt.cross_provider_inference);
2060 }
2061
2062 /// A task that never reaches admission must never reach the Router. This
2063 /// is the shape of a gate rejection or a capacity block: the caller binds,
2064 /// decides not to proceed, and no provider was contacted.
2065 #[test]
2066 fn a_task_that_is_never_admitted_costs_no_router_call() {
2067 let router = StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
2068 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
2069
2070 // Unknown member: rejected during binding, before any cost.
2071 assert!(
2072 workflow
2073 .bind_member(None, Some("wizard"), full_session())
2074 .is_err()
2075 );
2076 // Conflicting identity: likewise.
2077 assert!(
2078 workflow
2079 .bind_member(Some("implementer"), Some("reviewer"), full_session())
2080 .is_err()
2081 );
2082 // A valid binding that the caller then abandons (gate reject / no slot).
2083 let _binding = workflow
2084 .bind_member(None, Some("builder"), full_session())
2085 .expect("valid binding");
2086
2087 assert_eq!(
2088 router.call_count(),
2089 0,
2090 "no router call may happen before a task is admitted"
2091 );
2092 }
2093
2094 #[tokio::test]
2095 async fn a_router_that_tries_to_move_the_route_fails_the_launch() {
2096 let workflow = workflow_with(
2097 Some(StaticFleetRouter::new(
2098 r#"{"reasoning":"max","model":"glm-4"}"#,
2099 )),
2100 GLM_FLEET,
2101 );
2102 let binding = workflow
2103 .bind_member(None, Some("builder"), full_session())
2104 .expect("bind");
2105
2106 let err = workflow
2107 .route_admitted_task(&binding, "anything")
2108 .await
2109 .expect_err("a route mutation must fail the launch");
2110 assert!(err.contains("frozen"), "{err}");
2111 }
2112
2113 #[tokio::test]
2114 async fn a_duplicate_reasoning_key_fails_the_launch() {
2115 let workflow = workflow_with(
2116 Some(StaticFleetRouter::new(
2117 r#"{"reasoning":"off","reasoning":"max"}"#,
2118 )),
2119 GLM_FLEET,
2120 );
2121 let binding = workflow
2122 .bind_member(None, Some("builder"), full_session())
2123 .expect("bind");
2124
2125 let err = workflow
2126 .route_admitted_task(&binding, "anything")
2127 .await
2128 .expect_err("duplicate key");
2129 assert!(err.contains("more than once"), "{err}");
2130 }
2131
2132 #[test]
2133 fn a_missing_router_fails_before_any_worker_is_dispatched() {
2134 let router_less = GLM_FLEET.replace("reasoning_router = \"luna-low\"\n", "");
2135 let document = FleetDocument::parse(&router_less).expect("parse");
2136 let workflow = ExactFleetWorkflow::for_tests(&document, id(), None);
2137
2138 let err = workflow
2139 .reject_unusable_auto_members()
2140 .expect_err("auto without a router must not start");
2141 assert!(err.contains("implementer"), "{err}");
2142 assert!(err.contains("reasoning router"), "{err}");
2143 assert!(
2144 err.contains("never fall back"),
2145 "the error must rule out legacy fallback: {err}"
2146 );
2147 }
2148
2149 #[test]
2150 fn a_fleet_with_no_auto_member_starts_without_a_router() {
2151 let text = r#"
2152 name = "pinned"
2153 schema = "exact"
2154
2155 [[members]]
2156 id = "auditor"
2157 provider = "zai"
2158 model = "glm-5"
2159 reasoning = "high"
2160 permissions = "read_only"
2161 "#;
2162 let document = FleetDocument::parse(text).expect("parse");
2163 let workflow = ExactFleetWorkflow::for_tests(
2164 &document,
2165 QualifiedFleetId {
2166 name: "pinned".to_string(),
2167 origin: "workspace".to_string(),
2168 },
2169 None,
2170 );
2171 workflow
2172 .reject_unusable_auto_members()
2173 .expect("no auto member means no router requirement");
2174 assert_eq!(workflow.snapshot().members().len(), 1);
2175 }
2176
2177 /// A Router whose credentials are locally absent fails the Workflow before
2178 /// any worker is dispatched — decided from local config, never from a live
2179 /// probe of the provider.
2180 #[test]
2181 fn a_locally_unusable_router_fails_before_any_worker_is_dispatched() {
2182 let document = FleetDocument::parse(GLM_FLEET).expect("parse");
2183 let err = ExactFleetWorkflow::for_tests_with_unavailable_router(
2184 &document,
2185 id(),
2186 "no credential configured for `openai`",
2187 )
2188 .expect_err("an unusable router must not start an auto Fleet");
2189
2190 assert!(err.contains("cannot start"), "{err}");
2191 assert!(err.contains("implementer"), "{err}");
2192 assert!(err.contains("no credential configured"), "{err}");
2193 assert!(err.contains("never fall back"), "{err}");
2194 }
2195
2196 #[test]
2197 fn the_frozen_route_pins_each_members_exact_provider_and_model() {
2198 let workflow = workflow_with(None, GLM_FLEET);
2199 let route = workflow
2200 .preflight
2201 .worker("implementer")
2202 .expect("preflighted worker");
2203
2204 assert_eq!(route.provider_id, "zai");
2205 assert_eq!(route.wire_model, "glm-5");
2206 let member = workflow
2207 .snapshot()
2208 .member("implementer")
2209 .expect("snapshot entry");
2210 assert!(
2211 member.requested_reasoning.is_auto(),
2212 "reasoning is decided per task, not baked into the frozen route"
2213 );
2214 }
2215
2216 /// Binding carries route and Runtime role, but no Fleet-owned authority.
2217 #[test]
2218 fn bound_members_use_runtime_roles_and_neutral_compatibility_fields() {
2219 let workflow = workflow_with(None, GLM_FLEET);
2220 for (id, expected_posture, expected_write) in [
2221 ("auditor", "reviewer", "read_only"),
2222 ("implementer", "implement", "workspace_write"),
2223 ] {
2224 let binding = workflow
2225 .bind_member(Some(id), None, full_session())
2226 .expect("bind");
2227 assert_eq!(
2228 binding.authority.posture_role, expected_posture,
2229 "{id} must resolve through Runtime's closed role policy"
2230 );
2231 assert_eq!(
2232 binding.authority.write_authority, expected_write,
2233 "{id} authority comes from the role posture, never a Fleet permissions block"
2234 );
2235 }
2236 }
2237
2238 /// #5575: a free-form member role keeps its identity and **fails closed**.
2239 ///
2240 /// This test previously asserted the opposite — `posture_role == "custom"`
2241 /// and `write_authority == "workspace_write"` — which is exactly the defect:
2242 /// `audit-lead` is a name nobody declared, and the exact driver answered it
2243 /// with the widest posture there is while the durable driver answered the
2244 /// same class of name with `general`. Identity is still preserved verbatim
2245 /// (`member_role`), but an undeclared label now buys the narrowest useful
2246 /// posture, not write authority.
2247 #[test]
2248 fn a_free_form_member_role_fails_closed_without_losing_identity() {
2249 const AUDIT_FLEET: &str = r#"
2250 name = "audit"
2251 schema = "exact"
2252
2253 [[members]]
2254 id = "auditor-one"
2255 role = "audit-lead"
2256 provider = "zai"
2257 model = "glm-5"
2258 permissions = "read_only"
2259 "#;
2260 let workflow = workflow_with(None, AUDIT_FLEET);
2261 let binding = workflow
2262 .bind_member(Some("auditor-one"), None, full_session())
2263 .expect("bind");
2264
2265 assert_eq!(binding.member_role, "audit-lead");
2266 assert_eq!(binding.authority.posture_role, "explore");
2267 assert_eq!(
2268 binding.authority.write_authority, "read_only",
2269 "an undeclared role name must never grant write authority; an \
2270 operator who wants the parent's posture spells the role `custom`"
2271 );
2272
2273 // The escape hatch is a declared role, not a typo.
2274 assert_eq!(
2275 ChildAuthority::from_runtime_role("custom", full_session()).write_authority,
2276 "workspace_write"
2277 );
2278 }
2279
2280 // ── Permission ceilings, as the child actually experiences them ─────────
2281
2282 /// `tools = false` means zero model tools — an empty allowlist, which the
2283 /// child registry treats as "nothing is visible and nothing is callable".
2284 #[test]
2285 fn spawn_refuses_empty_effective_toolset() {
2286 let authority = ChildAuthority::clamp(PermissionCeiling::ROUTER, full_session());
2287 let err = refuse_dropped_requested_tools(
2288 "router",
2289 "advisor",
2290 &["read_file".to_string(), "grep_files".to_string()],
2291 &authority,
2292 &authority,
2293 )
2294 .expect_err("a child that would start with no tools is refused");
2295 assert!(
2296 err.contains("Agent 'router' (role advisor) would start with no tools"),
2297 "{err}"
2298 );
2299 assert!(err.contains("requested [grep_files, read_file]"), "{err}");
2300 assert!(err.contains("role allows no tools"), "{err}");
2301 // An explicit empty request stays a deliberate tool-free child.
2302 refuse_dropped_requested_tools("router", "advisor", &[], &authority, &authority)
2303 .expect("explicit empty toolset is allowed");
2304 }
2305
2306 #[test]
2307 fn spawn_error_names_dropped_tools() {
2308 let workflow = workflow_with(None, GLM_FLEET);
2309 let mut binding = workflow
2310 .bind_member(None, Some("reviewer"), full_session())
2311 .expect("role lookup");
2312 let err = binding
2313 .narrow_for_task(
2314 None,
2315 Some(&["read_file".to_string(), "exec_shell".to_string()]),
2316 &[],
2317 None,
2318 )
2319 .expect_err("a requested tool the role denies is refused");
2320 assert!(err.contains("would lose requested tools"), "{err}");
2321 assert!(err.contains("dropped [exec_shell]"), "{err}");
2322 assert!(err.contains("requested [exec_shell, read_file]"), "{err}");
2323 assert!(
2324 err.contains("role allows all inherited tools except ["),
2325 "{err}"
2326 );
2327
2328 // Tools the role allows narrow cleanly.
2329 binding
2330 .narrow_for_task(None, Some(&["read_file".to_string()]), &[], None)
2331 .expect("an allowed narrowing succeeds");
2332 assert_eq!(
2333 binding.authority.allowed_tools.as_deref(),
2334 Some(&["read_file".to_string()] as &[String])
2335 );
2336 }
2337
2338 #[test]
2339 fn tools_false_yields_an_empty_tool_surface() {
2340 let authority = ChildAuthority::clamp(PermissionCeiling::ROUTER, full_session());
2341
2342 assert!(!authority.ceiling.tools);
2343 assert_eq!(
2344 authority.allowed_tools.as_deref(),
2345 Some(&[] as &[String]),
2346 "tools = false must be an empty allowlist, not an absent one"
2347 );
2348 assert_eq!(authority.write_authority, "read_only");
2349 assert_eq!(authority.max_depth, 0);
2350 }
2351
2352 /// `network_tool = false` removes every model-visible network, browser,
2353 /// and remote-MCP surface except the `Web` family's two read-only actions
2354 /// — even when `tools = true`. The family *name* must survive the deny
2355 /// list so the child registry's action seam can grant exactly
2356 /// `search`/`fetch`; every other browsing spelling is denied.
2357 #[test]
2358 fn network_disabled_denies_every_network_surface_even_with_tools_enabled() {
2359 let member = PermissionCeiling::preset("read_write").expect("preset");
2360 assert!(member.tools);
2361 assert!(!member.network_tool);
2362
2363 let authority = ChildAuthority::clamp(member, full_session());
2364
2365 assert!(
2366 authority.allowed_tools.is_none(),
2367 "a tool-using member keeps full inheritance, narrowed by the deny list"
2368 );
2369 for expected in [
2370 "web.run",
2371 "web_run",
2372 "web_search",
2373 "fetch_url",
2374 "wait_for_dev_server",
2375 "github",
2376 "mcp*",
2377 ] {
2378 assert!(
2379 authority
2380 .disallowed_tools
2381 .iter()
2382 .any(|name| name == expected),
2383 "{expected} must be denied: {:?}",
2384 authority.disallowed_tools
2385 );
2386 }
2387 // The canonical family name is what the read-only web surface
2388 // dispatches under; only its reaching spellings are denied.
2389 assert!(
2390 !authority.disallowed_tools.iter().any(|name| name == "Web"),
2391 "the Web family name must survive so search/fetch stay reachable: {:?}",
2392 authority.disallowed_tools
2393 );
2394
2395 // A member that IS allowed a network tool gets no such deny list.
2396 let networked = ChildAuthority::clamp(
2397 PermissionCeiling::preset("full").expect("preset"),
2398 full_session(),
2399 );
2400 assert!(networked.ceiling.network_tool);
2401 assert!(networked.disallowed_tools.is_empty());
2402 }
2403
2404 /// The browsing capability is registered under several names, and `web.run`
2405 /// is the one a deny list stopping at the `Web` family name leaves behind.
2406 /// A network-denied member that can still call `web.run` is not
2407 /// network-denied, so every spelling *except* the family name itself —
2408 /// which the action seam bounds to `search`/`fetch` — stays on the list.
2409 #[test]
2410 fn network_disabled_denies_the_canonical_web_run_surface_and_its_aliases() {
2411 let authority = ChildAuthority::clamp(
2412 PermissionCeiling::preset("read_write").expect("preset"),
2413 full_session(),
2414 );
2415
2416 let denied = |name: &str| {
2417 let lowered = name.to_ascii_lowercase();
2418 authority.disallowed_tools.iter().any(|rule| {
2419 let rule = rule.to_ascii_lowercase();
2420 rule.strip_suffix('*')
2421 .map_or(rule == lowered, |prefix| lowered.starts_with(prefix))
2422 })
2423 };
2424
2425 for name in [
2426 "web.run",
2427 "web_run",
2428 "web_search",
2429 "web.fetch",
2430 "web_fetch",
2431 "fetch_url",
2432 "wait_for_dev_server",
2433 "browse",
2434 "browser",
2435 ] {
2436 assert!(
2437 denied(name),
2438 "{name} must be denied: {:?}",
2439 authority.disallowed_tools
2440 );
2441 }
2442 // The family name itself is what the read-only search/fetch surface
2443 // dispatches under; the action seam and the URL-input guard bound it.
2444 assert!(
2445 !denied("Web"),
2446 "the Web family name must survive a network denial: {:?}",
2447 authority.disallowed_tools
2448 );
2449 // The globs must not reach past the browsing family.
2450 for name in ["read_file", "run_tests", "Git", "grep_files"] {
2451 assert!(!denied(name), "{name} is not a network surface");
2452 }
2453 }
2454
2455 /// `rlm` reaches the network without ever naming a network tool: `open`
2456 /// fetches a `url` by calling `FetchUrlTool` in-process, and `eval` runs
2457 /// Python that owns a socket API. Denying `fetch_url` sees neither call, so
2458 /// both actions carry their own deny-list entries.
2459 #[test]
2460 fn network_disabled_denies_the_in_process_rlm_reach() {
2461 let authority = ChildAuthority::clamp(
2462 PermissionCeiling::preset("read_write").expect("preset"),
2463 full_session(),
2464 );
2465
2466 let denied = |name: &str| {
2467 let lowered = name.to_ascii_lowercase();
2468 authority.disallowed_tools.iter().any(|rule| {
2469 let rule = rule.to_ascii_lowercase();
2470 rule.strip_suffix('*')
2471 .map_or(rule == lowered, |prefix| lowered.starts_with(prefix))
2472 })
2473 };
2474
2475 for reaching in ["rlm_open", "rlm_eval"] {
2476 assert!(
2477 denied(reaching),
2478 "{reaching} reaches the network in-process and must be denied: {:?}",
2479 authority.disallowed_tools
2480 );
2481 }
2482 // The fail-closed narrowing is deliberate but *bounded*: the bounded
2483 // local metadata actions survive, and so does the family itself, so the
2484 // per-action seam has something left to permit.
2485 for kept in ["rlm", "rlm_session_objects", "rlm_configure", "rlm_close"] {
2486 assert!(
2487 !denied(kept),
2488 "{kept} is bounded local metadata and must survive a network denial"
2489 );
2490 }
2491 }
2492
2493 /// The deny-list sentinel has to actually be on the deny list, or every
2494 /// posture check derived from it silently reads "network allowed".
2495 #[test]
2496 fn the_network_denial_sentinel_is_installed_by_a_network_denial() {
2497 assert!(
2498 NETWORK_TOOL_DENYLIST.contains(&NETWORK_DENIAL_SENTINEL),
2499 "{NETWORK_DENIAL_SENTINEL} must be an explicit entry, not a glob match"
2500 );
2501 let authority = ChildAuthority::clamp(
2502 PermissionCeiling::preset("read_write").expect("preset"),
2503 full_session(),
2504 );
2505 assert!(
2506 authority
2507 .disallowed_tools
2508 .iter()
2509 .any(|rule| rule == NETWORK_DENIAL_SENTINEL),
2510 "a network denial must install the sentinel verbatim: {:?}",
2511 authority.disallowed_tools
2512 );
2513 // …and a network-*capable* member must not, or the sentinel would read
2514 // as denied for everyone.
2515 let networked = ChildAuthority::clamp(
2516 PermissionCeiling::preset("full").expect("preset"),
2517 full_session(),
2518 );
2519 assert!(
2520 !networked
2521 .disallowed_tools
2522 .iter()
2523 .any(|rule| rule == NETWORK_DENIAL_SENTINEL)
2524 );
2525 }
2526
2527 /// Every network-denied preset — read_only/read-only inspection included — leaves the
2528 /// `Web` family name reachable and seals each of its reaching spellings.
2529 /// This is the deny-list half of the read-only web-search contract; the
2530 /// registry-side half (exactly `search`/`fetch`, with URL-addressed calls
2531 /// refused) is asserted in `subagent/tests.rs`.
2532 #[test]
2533 fn every_network_denial_leaves_web_search_reachable_by_family_name() {
2534 for preset in ["analyst", "read_only", "verifier", "read_write"] {
2535 let authority = ChildAuthority::clamp(
2536 PermissionCeiling::preset(preset).expect("preset"),
2537 full_session(),
2538 );
2539 assert!(
2540 !authority.ceiling.network_tool,
2541 "{preset} is network-denied"
2542 );
2543 assert!(
2544 !authority.disallowed_tools.iter().any(|rule| rule == "Web"),
2545 "{preset} must keep the Web family name: {:?}",
2546 authority.disallowed_tools
2547 );
2548 for sealed in [
2549 "web_*",
2550 "web.*",
2551 "web.run",
2552 "web_run",
2553 "web_search",
2554 "web.fetch",
2555 "web_fetch",
2556 "fetch_url",
2557 "wait_for_dev_server",
2558 "github",
2559 "mcp*",
2560 ] {
2561 assert!(
2562 authority.disallowed_tools.iter().any(|rule| rule == sealed),
2563 "{preset} must deny {sealed}: {:?}",
2564 authority.disallowed_tools
2565 );
2566 }
2567 }
2568 }
2569
2570 /// A member saved as `write = false` must not receive a mutating surface —
2571 /// including the raw shell a `verifier`-shaped ceiling keeps for running
2572 /// checks. `rm -rf` mutates a workspace exactly as well as `write_file`,
2573 /// and a receipt that says `write=false` while the child holds `exec_shell`
2574 /// is not true.
2575 #[test]
2576 fn a_read_only_member_gets_a_truthful_non_mutating_tool_contract() {
2577 let verifier = PermissionCeiling::preset("verifier").expect("preset");
2578 assert!(!verifier.write);
2579 assert_eq!(verifier.shell, ShellCeiling::Full);
2580
2581 let authority = ChildAuthority::clamp(verifier, full_session());
2582 assert_eq!(authority.write_authority, "read_only");
2583
2584 let denied = |name: &str| {
2585 authority.disallowed_tools.iter().any(|rule| {
2586 rule == name || rule.strip_suffix('*').is_some_and(|p| name.starts_with(p))
2587 })
2588 };
2589
2590 // `rlm_eval` belongs on this list for the same reason `exec_shell` does:
2591 // the Python it runs writes files. A tool is a mutation primitive
2592 // because of what it can do, not because of what it is called.
2593 for mutating in [
2594 "write_file",
2595 "edit_file",
2596 "apply_patch",
2597 "fim_edit",
2598 "rlm_eval",
2599 ] {
2600 assert!(
2601 denied(mutating),
2602 "{mutating} must be denied for a read-only member: {:?}",
2603 authority.disallowed_tools
2604 );
2605 }
2606 for raw_shell in [
2607 "Bash",
2608 "exec_shell",
2609 "exec_shell_interact",
2610 "task_shell_start",
2611 "terminal/run",
2612 ] {
2613 assert!(
2614 denied(raw_shell),
2615 "{raw_shell} is a general mutation primitive: {:?}",
2616 authority.disallowed_tools
2617 );
2618 }
2619 // What the member is *for* survives: the bounded verification surface.
2620 // (`rlm_open` is absent from this list only because the `verifier`
2621 // preset is also network-denied; the write contract alone keeps it —
2622 // see `a_write_denial_alone_keeps_local_rlm_loading`.)
2623 for kept in [
2624 "Run",
2625 "run_tests",
2626 "run_verifiers",
2627 "read_file",
2628 "grep_files",
2629 "rlm",
2630 ] {
2631 assert!(!denied(kept), "{kept} must stay available to a verifier");
2632 }
2633
2634 // A write-capable member is untouched by this contract.
2635 let builder = ChildAuthority::clamp(
2636 PermissionCeiling::preset("read_write").expect("preset"),
2637 full_session(),
2638 );
2639 assert!(builder.ceiling.write);
2640 for kept in ["write_file", "apply_patch", "exec_shell"] {
2641 assert!(
2642 !builder.disallowed_tools.iter().any(|rule| rule == kept),
2643 "{kept} must stay available to a write-capable member"
2644 );
2645 }
2646 }
2647
2648 /// The two denials are separate contracts and must not bleed into each
2649 /// other. A member that may not *write* can still load a large local file
2650 /// into an RLM kernel and read it — that is analysis, not mutation. Only
2651 /// `eval` goes, because only `eval` runs code.
2652 #[test]
2653 fn a_write_denial_alone_keeps_local_rlm_loading() {
2654 let member = PermissionCeiling {
2655 write: false,
2656 network_tool: true,
2657 shell: ShellCeiling::ReadOnly,
2658 delegation_depth: 0,
2659 tools: true,
2660 };
2661 let authority = ChildAuthority::clamp(member, full_session());
2662 assert!(!authority.ceiling.write);
2663 assert!(authority.ceiling.network_tool);
2664
2665 let denied = |name: &str| authority.disallowed_tools.iter().any(|rule| rule == name);
2666
2667 assert!(denied("rlm_eval"), "eval runs code, so it mutates");
2668 for kept in ["rlm", "rlm_open", "rlm_session_objects", "rlm_close"] {
2669 assert!(
2670 !denied(kept),
2671 "{kept} loads and inspects; it does not mutate: {:?}",
2672 authority.disallowed_tools
2673 );
2674 }
2675 }
2676
2677 /// The parent posture always wins. A saved `full` member inside a
2678 /// read-only, no-network, no-shell session runs at the session's ceiling.
2679 #[test]
2680 fn the_parent_ceiling_wins_over_a_wider_saved_member() {
2681 let session = PermissionCeiling {
2682 write: false,
2683 network_tool: false,
2684 shell: ShellCeiling::ReadOnly,
2685 delegation_depth: 0,
2686 tools: true,
2687 };
2688 let member = PermissionCeiling::preset("full").expect("preset");
2689 assert!(member.write && member.network_tool);
2690
2691 let authority = ChildAuthority::clamp(member, session);
2692
2693 assert!(!authority.ceiling.write, "a Fleet may not grant write");
2694 assert!(
2695 !authority.ceiling.network_tool,
2696 "a Fleet may not grant a network tool"
2697 );
2698 assert_eq!(authority.ceiling.shell, ShellCeiling::ReadOnly);
2699 assert_eq!(authority.ceiling.delegation_depth, 0);
2700 assert_eq!(authority.write_authority, "read_only");
2701 assert_eq!(authority.max_depth, 0);
2702 assert_eq!(authority.posture_role, "explore");
2703 assert!(!authority.disallowed_tools.is_empty());
2704 }
2705
2706 /// A read-only session cannot be widened by a session that *is* permissive
2707 /// either — clamping is symmetric, and takes the narrower side each way.
2708 #[test]
2709 fn clamping_takes_the_narrower_side_of_every_field() {
2710 let narrow_member = PermissionCeiling {
2711 write: false,
2712 network_tool: false,
2713 shell: ShellCeiling::None,
2714 delegation_depth: 0,
2715 tools: true,
2716 };
2717 let authority = ChildAuthority::clamp(narrow_member, full_session());
2718
2719 assert!(!authority.ceiling.write);
2720 assert_eq!(authority.ceiling.shell, ShellCeiling::None);
2721 assert_eq!(authority.ceiling.delegation_depth, 0);
2722 }
2723
2724 // ── Preflight ──────────────────────────────────────────────────────────
2725
2726 /// Z.AI GLM routes express only thinking enabled/disabled, so `high` and
2727 /// `max` must not be reported as two distinct provider-effective tiers.
2728 #[test]
2729 fn glm_routes_report_an_enabled_disabled_provider_control() {
2730 let capability = reasoning_capability_for_route(
2731 ProviderKind::Zai,
2732 crate::config::DEFAULT_ZAI_BASE_URL,
2733 crate::config::ZAI_GLM_5_2_MODEL,
2734 );
2735
2736 assert_eq!(
2737 capability.control,
2738 ProviderReasoningControl::EnabledDisabled,
2739 "Z.AI's request shaping emits only thinking enabled/disabled"
2740 );
2741 assert!(!capability.supports_native_adaptive());
2742 assert_eq!(
2743 capability.provider_effective(ReasoningTier::High),
2744 ProviderEffectiveReasoning::Enabled
2745 );
2746 assert_eq!(
2747 capability.provider_effective(ReasoningTier::Off),
2748 ProviderEffectiveReasoning::Disabled
2749 );
2750 }
2751
2752 /// DeepSeek varies `reasoning_effort` per tier, so its tiers are real.
2753 #[test]
2754 fn a_route_that_varies_its_wire_value_reports_distinct_tiers() {
2755 let capability = reasoning_capability_for_route(
2756 ProviderKind::Deepseek,
2757 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
2758 "deepseek-v4-pro",
2759 );
2760 assert_eq!(capability.control, ProviderReasoningControl::Tiers);
2761 }
2762
2763 /// First-party DeepSeek routes document `reasoning_effort` low/high/max
2764 /// on the wire (no medium), so `low` is a real tier there. The capability
2765 /// must report the tier the route *sends*, not the tier the selector
2766 /// named: low reaches the wire as low, medium rounds up to high because
2767 /// the dialect has no such value (#52).
2768 #[test]
2769 fn a_deepseek_route_reports_low_as_low_and_medium_as_high() {
2770 let capability = reasoning_capability_for_route(
2771 ProviderKind::Deepseek,
2772 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
2773 "deepseek-v4-pro",
2774 );
2775
2776 // Exactly what the request shaping does, read back off the capability.
2777 for (requested, expected) in [
2778 (ReasoningTier::Low, ReasoningTier::Low),
2779 (ReasoningTier::Medium, ReasoningTier::High),
2780 (ReasoningTier::High, ReasoningTier::High),
2781 (ReasoningTier::Max, ReasoningTier::Max),
2782 (ReasoningTier::Off, ReasoningTier::Off),
2783 ] {
2784 assert_eq!(
2785 capability.wire_tier(requested),
2786 expected,
2787 "requested {requested:?} must be reported as what the wire carries"
2788 );
2789 let (effective, normalized) = capability.normalize(requested);
2790 assert_eq!(effective, expected);
2791 assert_eq!(normalized, requested != expected);
2792 }
2793
2794 // And the resolver carries that all the way onto the receipt.
2795 let resolved = codewhale_workflow::resolve_exact_member_reasoning(
2796 "implementer",
2797 &codewhale_workflow::FrozenRoute {
2798 provider: "deepseek".to_string(),
2799 model: "deepseek-v4-pro".to_string(),
2800 },
2801 RequestedReasoning::Low,
2802 &capability,
2803 &RouterAvailability::Absent,
2804 None,
2805 None,
2806 )
2807 .expect("resolve");
2808 assert_eq!(resolved.requested(), RequestedReasoning::Low);
2809 assert_eq!(
2810 resolved.effective(),
2811 codewhale_workflow::EffectiveReasoning::Tier(ReasoningTier::Low)
2812 );
2813 assert!(!resolved.capability_normalized());
2814 }
2815
2816 /// Routes whose dialect has no low tier still collapse low onto high, and
2817 /// the capability must say so instead of reporting a `low` the wire never
2818 /// carried. CodeWhale's normalizer keeps the historic low/medium → high
2819 /// coercion for these DeepSeek-compatible hosted routes because their own
2820 /// wire contracts are not verified.
2821 #[test]
2822 fn a_route_that_collapses_low_onto_high_says_so_instead_of_reporting_low() {
2823 let capability = reasoning_capability_for_route(
2824 ProviderKind::Siliconflow,
2825 crate::config::DEFAULT_SILICONFLOW_BASE_URL,
2826 "deepseek-ai/DeepSeek-V4-Pro",
2827 );
2828
2829 for (requested, expected) in [
2830 (ReasoningTier::Low, ReasoningTier::High),
2831 (ReasoningTier::Medium, ReasoningTier::High),
2832 (ReasoningTier::High, ReasoningTier::High),
2833 (ReasoningTier::Max, ReasoningTier::Max),
2834 (ReasoningTier::Off, ReasoningTier::Off),
2835 ] {
2836 assert_eq!(
2837 capability.wire_tier(requested),
2838 expected,
2839 "requested {requested:?} must be reported as what the wire carries"
2840 );
2841 let (effective, normalized) = capability.normalize(requested);
2842 assert_eq!(effective, expected);
2843 assert_eq!(normalized, requested != expected);
2844 }
2845 }
2846
2847 /// Preflight resolves the provider, canonicalizes the model, identifies the
2848 /// endpoint, and decides credential readiness — all from local config.
2849 #[test]
2850 fn preflight_freezes_provider_model_endpoint_and_local_readiness() {
2851 let _env_lock = crate::test_support::lock_test_env();
2852 let _key = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2853 let config = Config {
2854 provider: Some("zai".to_string()),
2855 ..Default::default()
2856 };
2857
2858 let route = preflight_route(
2859 "implementer",
2860 "zai",
2861 crate::config::ZAI_GLM_5_2_MODEL,
2862 &config,
2863 )
2864 .expect("preflight");
2865
2866 assert_eq!(route.member_id, "implementer");
2867 assert_eq!(route.provider_kind, "zai");
2868 assert_eq!(route.wire_model, crate::config::ZAI_GLM_5_2_MODEL);
2869 assert!(!route.endpoint.host.is_empty());
2870 assert!(!route.endpoint.host.contains('/'));
2871 assert_eq!(route.credential, CredentialReadiness::Configured);
2872 route.require_ready().expect("ready");
2873
2874 // The receipt and the child spawn read the same canonical wire model.
2875 assert_eq!(route.frozen().model, route.wire_model);
2876 }
2877
2878 /// A keyless local provider is valid, and is decided without a probe.
2879 #[test]
2880 fn a_keyless_local_provider_preflights_as_ready() {
2881 let _env_lock = crate::test_support::lock_test_env();
2882 let config = Config {
2883 provider: Some("ollama".to_string()),
2884 ..Default::default()
2885 };
2886
2887 let Ok(route) = preflight_route("worker", "ollama", "qwen3", &config) else {
2888 // A model id this build does not know is a different failure than
2889 // the one under test; skip rather than assert on the catalog.
2890 return;
2891 };
2892 assert_eq!(route.credential, CredentialReadiness::KeylessLocal);
2893 assert!(route.credential.is_ready());
2894 route.require_ready().expect("keyless local is valid");
2895 assert!(route.endpoint.local, "a local runtime is marked local");
2896 }
2897
2898 #[test]
2899 fn ollama_cloud_and_custom_remote_preflight_require_route_scoped_credentials() {
2900 let _env_lock = crate::test_support::lock_test_env();
2901 let temp = tempfile::tempdir().expect("isolated credential home");
2902 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
2903 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2904 let _ollama_cloud_key = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
2905 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
2906 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
2907 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
2908 codewhale_secrets::Secrets::auto_detect()
2909 .set("ollama", "legacy-cloud-key")
2910 .expect("seed released Ollama Cloud slot");
2911
2912 let cloud = Config {
2913 provider: Some("deepseek".to_string()),
2914 providers: Some(crate::config::ProvidersConfig {
2915 ollama: crate::config::ProviderConfig {
2916 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
2917 ..Default::default()
2918 },
2919 ..Default::default()
2920 }),
2921 ..Default::default()
2922 };
2923 let cloud_route = preflight_route(
2924 "cloud-worker",
2925 "ollama",
2926 crate::config::DEFAULT_OLLAMA_MODEL,
2927 &cloud,
2928 )
2929 .expect("official Cloud route");
2930 assert_eq!(cloud_route.provider_id, "ollama-cloud");
2931 assert_eq!(cloud_route.provider_config_id.as_deref(), Some("ollama"));
2932 assert_eq!(cloud_route.provider_kind, "ollama-cloud");
2933 assert_eq!(cloud_route.credential, CredentialReadiness::Configured);
2934 assert!(!cloud_route.endpoint.local);
2935 cloud_route.require_ready().expect("Cloud env key is ready");
2936
2937 let custom_remote = Config {
2938 provider: Some("ollama".to_string()),
2939 providers: Some(crate::config::ProvidersConfig {
2940 ollama: crate::config::ProviderConfig {
2941 base_url: Some("https://ollama-gateway.example.test/v1".to_string()),
2942 ..Default::default()
2943 },
2944 ..Default::default()
2945 }),
2946 ..Default::default()
2947 };
2948 let custom_route = preflight_route(
2949 "custom-worker",
2950 "ollama",
2951 crate::config::DEFAULT_OLLAMA_MODEL,
2952 &custom_remote,
2953 )
2954 .expect("custom route still resolves structurally");
2955 assert!(matches!(
2956 custom_route.credential,
2957 CredentialReadiness::Missing { .. }
2958 ));
2959 assert!(!custom_route.endpoint.local);
2960 assert!(custom_route.require_ready().is_err());
2961 }
2962
2963 #[tokio::test]
2964 async fn legacy_ollama_cloud_fleet_start_builds_clients_from_the_frozen_source_route() {
2965 let _env_lock = crate::test_support::lock_test_env();
2966 let temp = tempfile::tempdir().expect("isolated credential home");
2967 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
2968 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2969 let _cloud_env = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
2970 let _official_env = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
2971 codewhale_secrets::Secrets::auto_detect()
2972 .set("ollama", "legacy-cloud-fleet-key")
2973 .expect("seed released Ollama Cloud slot");
2974
2975 let config = Config {
2976 provider: Some("deepseek".to_string()),
2977 providers: Some(crate::config::ProvidersConfig {
2978 ollama: crate::config::ProviderConfig {
2979 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
2980 model: Some(crate::config::DEFAULT_OLLAMA_CLOUD_MODEL.to_string()),
2981 ..Default::default()
2982 },
2983 ..Default::default()
2984 }),
2985 ..Default::default()
2986 };
2987 let document = FleetDocument::parse(&format!(
2988 r#"
2989 name = "glm-pair"
2990 schema = "exact"
2991
2992 [[members]]
2993 id = "cloud-worker"
2994 role = "builder"
2995 provider = "ollama"
2996 model = "{}"
2997 reasoning = "medium"
2998 permissions = "read_only"
2999 "#,
3000 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL
3001 ))
3002 .expect("legacy Cloud Fleet parses");
3003
3004 // `capture` is the real Workflow-start path: it preflights readiness,
3005 // constructs every worker client, and freezes the snapshot.
3006 let workflow = ExactFleetWorkflow::capture(
3007 &document,
3008 id(),
3009 "2026-08-14T00:00:00Z",
3010 Some(&config),
3011 &[],
3012 )
3013 .expect("legacy Cloud Fleet starts");
3014 let route = workflow
3015 .preflight
3016 .worker("cloud-worker")
3017 .expect("preflighted worker");
3018 assert_eq!(route.provider_id, "ollama-cloud");
3019 assert_eq!(route.provider_config_id.as_deref(), Some("ollama"));
3020
3021 let binding = workflow
3022 .bind_member(Some("cloud-worker"), None, full_session())
3023 .expect("worker binds");
3024 let launch = workflow
3025 .route_admitted_task(&binding, "verify the frozen Cloud route")
3026 .await
3027 .expect("manual-tier launch needs no provider call");
3028 assert_eq!(launch.provider, "ollama-cloud");
3029 assert_eq!(launch.receipt.provider, "ollama-cloud");
3030
3031 let router_profile = ReasoningRouterProfile::parse(&format!(
3032 r#"
3033 name = "legacy-cloud-router"
3034 schema = "reasoning_router"
3035 provider = "ollama"
3036 model = "{}"
3037 call_reasoning = "low"
3038 "#,
3039 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL
3040 ))
3041 .expect("legacy Cloud router profile parses");
3042 let captured =
3043 CapturedReasoningRouter::from_profile(&router_profile, "workspace".to_string());
3044 let live = LiveFleetRouter::bind(&captured, &config)
3045 .expect("legacy Cloud Router binds its source table and secret");
3046 assert_eq!(live.route.provider_id, "ollama-cloud");
3047 assert_eq!(live.route.provider_config_id.as_deref(), Some("ollama"));
3048 assert_eq!(live.client.api_provider(), ProviderKind::OllamaCloud);
3049 assert_eq!(
3050 live.client.base_url(),
3051 codewhale_config::provider::OLLAMA_CLOUD_BASE_URL
3052 );
3053 }
3054
3055 /// A tier label is a selector concept; what a request may carry is a
3056 /// provider concept. The value placed on a call must come from the route
3057 /// normalizer the client actually uses, or a Codex-routed Router is called
3058 /// at the provider default while its receipt claims a tier.
3059 #[test]
3060 fn a_call_reasoning_value_is_shaped_by_the_configured_route_not_a_tier_label() {
3061 // A tiered non-Codex route spells the tiers the ordinary way, after
3062 // the same route normalization the client performs (first-party
3063 // DeepSeek keeps a real `low`; medium still rounds up to high).
3064 for (tier, expected) in [
3065 (ReasoningTier::Off, "off"),
3066 (ReasoningTier::High, "high"),
3067 (ReasoningTier::Max, "max"),
3068 ] {
3069 assert_eq!(
3070 route_reasoning_setting(
3071 ProviderKind::Deepseek,
3072 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
3073 "deepseek-v4-pro",
3074 tier,
3075 ),
3076 expected,
3077 "{tier:?} on a deepseek route"
3078 );
3079 }
3080
3081 // Codex is the case a bare tier label gets wrong in both directions:
3082 // it has no `off`, and its ladder now spells three separate top rungs
3083 // (`xhigh`, `max`, `ultra`) that the roster publishes per model.
3084 let codex = |tier| {
3085 route_reasoning_setting(
3086 ProviderKind::OpenaiCodex,
3087 "https://chatgpt.com/backend-api/codex",
3088 "gpt-5.6-codex",
3089 tier,
3090 )
3091 };
3092 assert_eq!(codex(ReasoningTier::Max), "max");
3093 assert_eq!(codex(ReasoningTier::Low), "low");
3094 assert_ne!(
3095 codex(ReasoningTier::Off),
3096 "off",
3097 "an always-thinking route cannot be asked for `off`; sending the label \
3098 would take the provider default while the receipt claimed a tier"
3099 );
3100 }
3101
3102 /// An unresolvable provider fails preflight rather than reaching a launch.
3103 #[test]
3104 fn an_unresolvable_provider_fails_preflight() {
3105 let config = Config::default();
3106 let err = preflight_route("implementer", "not-a-provider", "whatever", &config)
3107 .expect_err("unresolvable provider");
3108 assert!(matches!(err, PreflightError::ProviderUnresolved { .. }));
3109 }
3110
3111 // ── Receipts ───────────────────────────────────────────────────────────
3112
3113 /// The receipt is the durable artifact. It must carry every side of the
3114 /// decision — including which service chose the tier and what that call was
3115 /// configured to cost — and must store no task text, path, or key.
3116 #[tokio::test]
3117 async fn a_launch_receipt_names_the_service_route_and_call_cost_without_content() {
3118 let workflow = workflow_with(
3119 Some(StaticFleetRouter::new(r#"{"reasoning":"max"}"#)),
3120 GLM_FLEET,
3121 );
3122 let binding = workflow
3123 .bind_member(None, Some("builder"), full_session())
3124 .expect("bind");
3125
3126 let launch = workflow
3127 .route_admitted_task(&binding, "refactor /Users/hunter/app with ZAI_API_KEY=zzz")
3128 .await
3129 .expect("launch");
3130 let receipt = &launch.receipt;
3131
3132 assert_eq!(receipt.fleet, "workspace/glm-pair");
3133 assert_eq!(receipt.schema_kind, "exact");
3134 assert_eq!(receipt.member_id, "implementer");
3135 assert_eq!(receipt.member_role, "implement");
3136 assert_eq!(receipt.provider, "zai");
3137 assert_eq!(receipt.model, "glm-5");
3138 assert_eq!(receipt.requested_reasoning, "auto");
3139 assert_eq!(receipt.effective_reasoning, "max");
3140 assert_eq!(receipt.selection_source, "fleet_router");
3141 assert!(!receipt.content_hash.is_empty());
3142
3143 // The service is labelled as a service, with its exact route and the
3144 // configured requested → provider-effective call reasoning.
3145 let router = receipt.router.as_ref().expect("router identity");
3146 assert_eq!(router.service_kind, "reasoning_router");
3147 assert_eq!(router.qualified(), "workspace/luna-low");
3148 assert_eq!(router.provider, "openai");
3149 assert_eq!(router.model, "gpt-5.6-luna");
3150 let call = router.call.as_ref().expect("call disclosure");
3151 assert_eq!(call.requested, "low");
3152 assert_eq!(call.effective, "low");
3153 assert_eq!(call.provider_effective, "low");
3154
3155 // Cross-provider inference happened (zai worker, openai router) and is
3156 // disclosed rather than implied away.
3157 assert!(receipt.cross_provider_inference);
3158 assert!(
3159 receipt.transport.contains("different provider"),
3160 "{}",
3161 receipt.transport
3162 );
3163
3164 // Disclosure without content.
3165 let disclosure = receipt.routing_summary.as_ref().expect("disclosure");
3166 assert!(disclosure.transmitted_bytes > 0);
3167 assert!(disclosure.content_hash.starts_with("sha256:"));
3168 assert!(disclosure.redacted);
3169
3170 let json = serde_json::to_string(receipt).expect("serialize");
3171 for forbidden in ["/Users/", "/home/", ".toml", "api_key", "zzz", "refactor"] {
3172 assert!(!json.contains(forbidden), "{forbidden} in {json}");
3173 }
3174
3175 // The visible line names every side and echoes no content.
3176 let line = receipt.line();
3177 for expected in [
3178 "requested=auto",
3179 "effective=max",
3180 "source=fleet_router",
3181 "reasoning_router:workspace/luna-low",
3182 "router_call_requested=low",
3183 ] {
3184 assert!(line.contains(expected), "{expected} missing from {line}");
3185 }
3186 assert!(!line.contains("refactor"), "{line}");
3187 }
3188
3189 /// A member's semantic role and its Runtime posture are separate
3190 /// facts and the receipt keeps both. An operator who named a member
3191 /// `auditor` must see `auditor` on the receipt, while the surface actually
3192 /// selected (`explore`, the fail-closed posture an undeclared role gets
3193 /// since #5575) is disclosed rather than substituted for the name.
3194 #[tokio::test]
3195 async fn a_receipt_records_the_posture_without_renaming_the_members_role() {
3196 const AUDIT_FLEET: &str = r#"
3197 name = "glm-pair"
3198 schema = "exact"
3199
3200 [[members]]
3201 id = "auditor"
3202 role = "auditor"
3203 provider = "zai"
3204 model = "glm-5"
3205 reasoning = "high"
3206 permissions = "read_only"
3207 "#;
3208 let workflow = workflow_with(None, AUDIT_FLEET);
3209 let binding = workflow
3210 .bind_member(None, Some("auditor"), full_session())
3211 .expect("bind");
3212
3213 // Enforcement uses the posture; it is not the operator's role name.
3214 assert_eq!(binding.member_role, "auditor");
3215 assert_eq!(binding.authority.posture_role, "explore");
3216
3217 let launch = workflow
3218 .route_admitted_task(&binding, "review the queue")
3219 .await
3220 .expect("launch");
3221 let receipt = &launch.receipt;
3222
3223 assert_eq!(receipt.member_role, "auditor");
3224 assert_eq!(receipt.posture_role.as_deref(), Some("explore"));
3225 let line = receipt.line();
3226 assert!(line.contains("(role auditor)"), "{line}");
3227 assert!(line.contains("posture=explore"), "{line}");
3228 }
3229
3230 // ── Search roots: where a workspace Fleet lives ────────────────────────
3231
3232 /// The Fleet store saves workspace Fleets under `<workspace>/.codewhale`,
3233 /// so that is the primary `workspace` origin; the workspace root stays a
3234 /// second origin for checked-in `fleets/<name>.toml` rosters.
3235 #[test]
3236 fn workspace_fleets_load_from_dot_codewhale_and_the_legacy_root() {
3237 let _lock = crate::test_support::lock_test_env();
3238 let home = tempfile::tempdir().expect("home");
3239 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
3240 let ws = tempfile::tempdir().expect("workspace");
3241
3242 let saved = ws.path().join(".codewhale").join("fleets");
3243 std::fs::create_dir_all(&saved).expect("saved fleets dir");
3244 std::fs::write(saved.join("glm-pair.toml"), GLM_FLEET).expect("write saved");
3245 let (document, id) =
3246 load_fleet_document("glm-pair", ws.path(), None).expect("saved fleet loads");
3247 assert_eq!(document.name(), "glm-pair");
3248 assert_eq!(id.origin, "workspace");
3249
3250 let checked_in = ws.path().join("fleets");
3251 std::fs::create_dir_all(&checked_in).expect("checked-in fleets dir");
3252 std::fs::write(
3253 checked_in.join("stopship.toml"),
3254 "name = \"stopship\"\n\n[roles]\nscout = \"scout\"\n",
3255 )
3256 .expect("write checked-in");
3257 let (document, id) =
3258 load_fleet_document("stopship", ws.path(), None).expect("checked-in fleet still loads");
3259 assert_eq!(document.name(), "stopship");
3260 assert_eq!(id.origin, "workspace_root");
3261
3262 // An exact Fleet in both workspace origins is ambiguous, and each
3263 // origin can be named explicitly.
3264 std::fs::write(checked_in.join("glm-pair.toml"), GLM_FLEET).expect("write twin");
3265 assert!(matches!(
3266 load_fleet_document("glm-pair", ws.path(), None),
3267 Err(NamedFleetError::AmbiguousFleet { .. })
3268 ));
3269 let (_, id) =
3270 load_fleet_document("workspace_root/glm-pair", ws.path(), None).expect("qualified");
3271 assert_eq!(id.origin, "workspace_root");
3272 }
3273
3274 #[test]
3275 fn fleet_names_that_leave_the_fleets_directory_are_refused() {
3276 let _lock = crate::test_support::lock_test_env();
3277 let home = tempfile::tempdir().expect("home");
3278 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
3279 let outer = tempfile::tempdir().expect("outer");
3280 let ws = outer.path().join("ws");
3281 std::fs::create_dir_all(ws.join("fleets")).expect("fleets dir");
3282 std::fs::write(outer.path().join("outside.toml"), GLM_FLEET).expect("outside");
3283 let absolute = outer.path().join("outside");
3284
3285 for name in [
3286 absolute.to_string_lossy().to_string(),
3287 "workspace_root/../../outside".to_string(),
3288 "codewhale_home/../outside".to_string(),
3289 "user/../outside".to_string(),
3290 ] {
3291 let err = load_fleet_document(&name, &ws, None).expect_err(&name);
3292 assert!(
3293 matches!(err, NamedFleetError::InvalidName),
3294 "{name}: expected InvalidName, got {err:?}"
3295 );
3296 let message = err.to_string();
3297 assert!(
3298 !message.contains(outer.path().to_string_lossy().as_ref()),
3299 "{message}"
3300 );
3301 }
3302 }
3303
3304 /// A Fleet saved through the store at workspace scope is found by the
3305 /// Workflow loader instead of being reported missing. Today the store's
3306 /// `schema = "fleet"` revision-2 document is not a schema the Workflow
3307 /// loader parses, so the load names that exact file and its schema; if a
3308 /// v2 bridge lands, the same call succeeds from the `workspace` origin.
3309 #[test]
3310 fn a_store_saved_workspace_fleet_is_found_by_load_fleet_document() {
3311 use crate::fleet::store::{FleetFile, FleetScope, save_fleet};
3312
3313 let _lock = crate::test_support::lock_test_env();
3314 let home = tempfile::tempdir().expect("home");
3315 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
3316 let ws = tempfile::tempdir().expect("workspace");
3317
3318 let fleet = FleetFile::new("Folder Pair".to_string(), None).expect("fleet");
3319 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
3320
3321 match load_fleet_document(&fleet.file_slug(), ws.path(), None) {
3322 // A v2 bridge may label the store scope `folder` rather than the
3323 // `workspace` search-root origin; either names this workspace.
3324 Ok((_, id)) => assert!(
3325 matches!(id.origin.as_str(), "workspace" | "folder"),
3326 "{}",
3327 id.origin
3328 ),
3329 Err(err) => {
3330 assert!(
3331 !matches!(err, NamedFleetError::NotFound(_)),
3332 "the saved Fleet must be found, got {err}"
3333 );
3334 let message = err.to_string();
3335 assert!(message.contains(&path.display().to_string()), "{message}");
3336 }
3337 }
3338 }
3339 }
3340
3341 /// `workflow(fleet:)` resolving saved v2 Fleets (store-first lookup, freeze
3342 /// into an exact snapshot, ambiguity against legacy/exact files).
3343 #[cfg(test)]
3344 mod saved_fleet_tests {
3345 use super::*;
3346 use crate::fleet::store::{FleetFile, FleetMember, FleetOperator, FleetScope, save_fleet};
3347 use crate::test_support::{EnvVarGuard, lock_test_env};
3348
3349 fn member(id: &str, pin: Option<(&str, &str)>) -> FleetMember {
3350 FleetMember {
3351 id: id.to_string(),
3352 display_name: None,
3353 shortlist: false,
3354 role: String::new(),
3355 model: pin.map(|(_, model)| model.to_string()),
3356 provider: pin.map(|(provider, _)| provider.to_string()),
3357 reasoning: None,
3358 instructions: None,
3359 requires: Vec::new(),
3360 }
3361 }
3362
3363 fn fleet(name: &str, members: Vec<FleetMember>) -> FleetFile {
3364 let mut fleet = FleetFile::new(name.to_string(), None).expect("fleet");
3365 fleet.members = members;
3366 fleet
3367 }
3368
3369 fn zai_session() -> Config {
3370 Config {
3371 provider: Some("zai".to_string()),
3372 reasoning_effort: Some("high".to_string()),
3373 ..Default::default()
3374 }
3375 }
3376
3377 fn session_ceiling() -> PermissionCeiling {
3378 PermissionCeiling {
3379 write: true,
3380 network_tool: true,
3381 shell: codewhale_workflow::ShellCeiling::Full,
3382 delegation_depth: codewhale_config::DEFAULT_SPAWN_DEPTH,
3383 tools: true,
3384 }
3385 }
3386
3387 #[test]
3388 fn a_personal_saved_fleet_loads_as_a_frozen_exact_document() {
3389 let _lock = lock_test_env();
3390 let home = tempfile::tempdir().expect("home");
3391 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3392 let ws = tempfile::tempdir().expect("workspace");
3393 let config = zai_session();
3394 let saved = fleet(
3395 "My fleet",
3396 vec![
3397 member("builder", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL))),
3398 member("reviewer", None),
3399 ],
3400 );
3401 let path = save_fleet(&saved, FleetScope::Personal, ws.path()).expect("save");
3402
3403 let (document, id) =
3404 load_fleet_document("My fleet", ws.path(), Some(&config)).expect("v2 loads");
3405
3406 assert_eq!(id.origin, "user");
3407 assert_eq!(id.name, "my-fleet");
3408 assert_eq!(document.source_path(), Some(path.as_path()));
3409 let exact = document.exact().expect("frozen into the exact schema");
3410 let builder = exact.member("builder").expect("builder");
3411 assert_eq!(
3412 (builder.provider.as_str(), builder.model.as_str()),
3413 ("zai", crate::config::ZAI_GLM_5_2_MODEL)
3414 );
3415 // No pin and no operator: the member inherits the live session route
3416 // and tier, resolved now rather than left open.
3417 let reviewer = exact.member("reviewer").expect("reviewer");
3418 assert_eq!(
3419 reviewer.provider,
3420 config.active_provider_identity().unwrap().key.as_str()
3421 );
3422 assert_eq!(reviewer.model, config.default_model());
3423 assert_eq!(reviewer.reasoning.as_str(), "high");
3424
3425 // The slug also resolves, and so does the qualified store scope.
3426 load_fleet_document("my-fleet", ws.path(), Some(&config)).expect("slug loads");
3427 load_fleet_document("user/My fleet", ws.path(), Some(&config)).expect("user/ loads");
3428 }
3429
3430 #[test]
3431 fn a_workspace_saved_fleet_loads_and_members_follow_the_operator_route() {
3432 let _lock = lock_test_env();
3433 let home = tempfile::tempdir().expect("home");
3434 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3435 let ws = tempfile::tempdir().expect("workspace");
3436 let mut saved = fleet("reviewers", vec![member("auditor", None)]);
3437 saved.operator = Some(FleetOperator {
3438 provider: "zai".to_string(),
3439 model: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
3440 reasoning: Some("low".to_string()),
3441 });
3442 let path = save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("save");
3443 assert!(path.starts_with(ws.path().join(".codewhale").join("fleets")));
3444
3445 // No session config is needed: nothing inherits the session route.
3446 let (document, id) = load_fleet_document("reviewers", ws.path(), None).expect("loads");
3447 assert_eq!(id.origin, "folder");
3448 let auditor = document.exact().unwrap().member("auditor").unwrap();
3449 assert_eq!(auditor.model, crate::config::ZAI_GLM_5_2_MODEL);
3450 assert_eq!(auditor.reasoning.as_str(), "low");
3451 }
3452
3453 #[test]
3454 fn a_saved_fleet_colliding_with_an_exact_file_is_ambiguous_and_names_both_paths() {
3455 let _lock = lock_test_env();
3456 let home = tempfile::tempdir().expect("home");
3457 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3458 let ws = tempfile::tempdir().expect("workspace");
3459 let saved_path = save_fleet(
3460 &fleet("glm-pair", vec![member("builder", None)]),
3461 FleetScope::Personal,
3462 ws.path(),
3463 )
3464 .expect("save");
3465 let exact_dir = ws.path().join("fleets");
3466 std::fs::create_dir_all(&exact_dir).unwrap();
3467 let exact_path = exact_dir.join("glm-pair.toml");
3468 std::fs::write(
3469 &exact_path,
3470 "name = \"glm-pair\"\nschema = \"exact\"\n\n[[members]]\nid = \"builder\"\nprovider = \"zai\"\nmodel = \"glm-5\"\n",
3471 )
3472 .unwrap();
3473
3474 let error = load_fleet_document("glm-pair", ws.path(), Some(&zai_session()))
3475 .expect_err("a v2 and an exact Fleet of one name must not shadow each other");
3476 let message = error.to_string();
3477 assert!(
3478 matches!(error, NamedFleetError::AmbiguousFleet { .. }),
3479 "{message}"
3480 );
3481 assert!(
3482 message.contains(&saved_path.display().to_string()),
3483 "{message}"
3484 );
3485 assert!(
3486 message.contains(&exact_path.display().to_string()),
3487 "{message}"
3488 );
3489
3490 // Qualifying either side resolves it.
3491 let (document, _) =
3492 load_fleet_document("user/glm-pair", ws.path(), Some(&zai_session())).expect("v2");
3493 assert_eq!(document.source_path(), Some(saved_path.as_path()));
3494 }
3495
3496 /// Saved Fleets carry session-vocabulary reasoning (an imported agent
3497 /// profile stores `ultra`, `xhigh`, `minimal`); freezing maps it onto an
3498 /// exact tier instead of failing the exact parser, a blank value inherits,
3499 /// and an unknown value is refused with the member named.
3500 #[test]
3501 fn saved_fleet_reasoning_in_session_vocabulary_freezes_to_exact_tiers() {
3502 let _lock = lock_test_env();
3503 let home = tempfile::tempdir().expect("home");
3504 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3505 let ws = tempfile::tempdir().expect("workspace");
3506 let pin = Some(("zai", crate::config::ZAI_GLM_5_2_MODEL));
3507 let mut ultra = member("ultra", pin);
3508 ultra.reasoning = Some("ultra".to_string());
3509 let mut minimal = member("minimal", pin);
3510 minimal.reasoning = Some("minimal".to_string());
3511 let mut blank = member("blank", pin);
3512 blank.reasoning = Some(" ".to_string());
3513 let mut saved = fleet("tiers", vec![ultra, minimal, blank]);
3514 saved.operator = Some(FleetOperator {
3515 provider: "zai".to_string(),
3516 model: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
3517 reasoning: Some("xhigh".to_string()),
3518 });
3519 save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("save");
3520
3521 let (document, _) = load_fleet_document("tiers", ws.path(), None).expect("freezes");
3522 let exact = document.exact().expect("exact");
3523 let tier = |id: &str| exact.member(id).expect(id).reasoning.as_str();
3524 assert_eq!(tier("ultra"), "max");
3525 assert_eq!(tier("minimal"), "low");
3526 // Blank inherits the operator's `xhigh`, which is the `max` tier.
3527 assert_eq!(tier("blank"), "max");
3528
3529 let mut bad = member("bad", pin);
3530 bad.reasoning = Some("turbo".to_string());
3531 save_fleet(
3532 &fleet("bad-tier", vec![bad]),
3533 FleetScope::Workspace,
3534 ws.path(),
3535 )
3536 .expect("save");
3537 let error = load_fleet_document("bad-tier", ws.path(), None).expect_err("refused");
3538 assert!(
3539 error.to_string().contains("member `bad` reasoning"),
3540 "{error}"
3541 );
3542 }
3543
3544 #[test]
3545 fn member_instructions_are_refused_rather_than_silently_dropped() {
3546 let _lock = lock_test_env();
3547 let home = tempfile::tempdir().expect("home");
3548 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3549 let ws = tempfile::tempdir().expect("workspace");
3550 let mut coach = member("coach", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL)));
3551 coach.instructions = Some("Always cite sources.".to_string());
3552 save_fleet(
3553 &fleet("coached", vec![coach]),
3554 FleetScope::Workspace,
3555 ws.path(),
3556 )
3557 .expect("save");
3558
3559 let error = load_fleet_document("coached", ws.path(), None).expect_err("refused");
3560 assert!(
3561 error.to_string().contains("`coach` has instructions"),
3562 "{error}"
3563 );
3564 }
3565
3566 /// The frozen snapshot is what runs: editing the saved file after capture
3567 /// moves nothing, and the inherited member's preflighted route is the same
3568 /// session route the snapshot names.
3569 #[test]
3570 fn frozen_routes_survive_a_mid_run_edit_and_inherit_matches_preflight() {
3571 let _lock = lock_test_env();
3572 let home = tempfile::tempdir().expect("home");
3573 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
3574 let _key = EnvVarGuard::set("ZAI_API_KEY", "zai-key");
3575 let ws = tempfile::tempdir().expect("workspace");
3576 let config = zai_session();
3577 let mut saved = fleet(
3578 "release",
3579 vec![
3580 member("builder", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL))),
3581 member("reviewer", None),
3582 ],
3583 );
3584 save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("save");
3585
3586 let (document, id) =
3587 load_fleet_document("release", ws.path(), Some(&config)).expect("loads");
3588 let roots = fleet_search_roots(ws.path());
3589 let workflow = ExactFleetWorkflow::capture(
3590 &document,
3591 id,
3592 "2026-09-22T00:00:00Z",
3593 Some(&config),
3594 &roots,
3595 )
3596 .expect("capture");
3597
3598 // Edit the saved Fleet mid-run.
3599 saved.members[0].model = Some("glm-4.6".to_string());
3600 save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("re-save");
3601
3602 let builder = workflow
3603 .bind_member(Some("builder"), None, session_ceiling())
3604 .expect("bind builder");
3605 assert_eq!(builder.route.wire_model, crate::config::ZAI_GLM_5_2_MODEL);
3606
3607 let reviewer = workflow
3608 .bind_member(Some("reviewer"), None, session_ceiling())
3609 .expect("bind reviewer");
3610 let frozen = workflow
3611 .snapshot()
3612 .members()
3613 .iter()
3614 .find(|member| member.id == "reviewer")
3615 .expect("reviewer in snapshot");
3616 assert_eq!(frozen.route.model, config.default_model());
3617 assert_eq!(reviewer.route.frozen().model, reviewer.route.wire_model);
3618 assert_eq!(
3619 reviewer.route.wire_model,
3620 crate::config::requested_model_for_provider(
3621 config.active_provider_identity().unwrap().provider,
3622 &config.default_model()
3623 )
3624 .expect("session model is a known route")
3625 );
3626 }
3627 }
3628
3628 lines RUST