返回 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::{ApiProvider, Config};
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 #[must_use]
72 pub(crate) fn fleet_search_roots(workspace: &std::path::Path) -> Vec<FleetSearchRoot> {
73 let mut roots = Vec::new();
74 if let Ok(home) = personal_fleet_root() {
75 roots.push(FleetSearchRoot::new("codewhale_home", home));
76 }
77 roots.push(FleetSearchRoot::new("workspace", workspace.to_path_buf()));
78 roots
79 }
80
81 /// Load a Fleet document by (optionally qualified) name from the standard
82 /// roots. Ambiguity between origins is surfaced, never resolved by shadowing.
83 pub(crate) fn load_fleet_document(
84 name: &str,
85 workspace: &std::path::Path,
86 ) -> Result<(FleetDocument, QualifiedFleetId), NamedFleetError> {
87 FleetDocument::load_by_name(name, &fleet_search_roots(workspace))
88 }
89
90 // ── Preflight: freeze the route, and check it while freezing ─────────────────
91
92 /// Derive a route's real reasoning capability from the request shaping the
93 /// client actually performs, rather than from a hand-maintained claims table.
94 ///
95 /// The probe builds the request body this exact route would receive for every
96 /// tier and compares them. Two tiers that produce a byte-identical body are not
97 /// two provider-effective tiers, whatever the selector calls them — this is why
98 /// Z.AI's GLM routes come back as
99 /// [`ProviderReasoningControl::EnabledDisabled`] and why nothing here can claim
100 /// provider-native adaptive for a route whose body does not say so.
101 #[must_use]
102 pub(crate) fn reasoning_capability_for_route(
103 provider: ApiProvider,
104 base_url: &str,
105 wire_model: &str,
106 ) -> ReasoningCapability {
107 let body_for = |effort: ReasoningEffort| -> String {
108 let mut body = serde_json::json!({});
109 let value = effort.api_value_for_route(provider, base_url, wire_model);
110 crate::client::apply_reasoning_effort(&mut body, value, provider);
111 // `reasoning_split` is a transport concern the client sets for every
112 // tier; it carries no reasoning depth, so it must not make tiers look
113 // distinct or make a no-control route look controllable.
114 if let Some(object) = body.as_object_mut() {
115 object.remove("reasoning_split");
116 }
117 body.to_string()
118 };
119
120 let off = body_for(ReasoningEffort::Off);
121 let above_off: Vec<String> = [
122 ReasoningEffort::Low,
123 ReasoningEffort::Medium,
124 ReasoningEffort::High,
125 ReasoningEffort::Max,
126 ]
127 .into_iter()
128 .map(body_for)
129 .collect();
130
131 let empty = "{}";
132 let all_empty = off == empty && above_off.iter().all(|body| body == empty);
133
134 let mut distinct = above_off.clone();
135 distinct.sort();
136 distinct.dedup();
137
138 let control = if all_empty {
139 ProviderReasoningControl::None
140 } else if distinct.len() == 1 && distinct[0] == off && off.contains("adaptive") {
141 // Every tier — including off — produces the same adaptive body: the
142 // provider genuinely chooses its own depth. Source-backed, not assumed.
143 ProviderReasoningControl::NativeAdaptive
144 } else if distinct.len() > 1 {
145 ProviderReasoningControl::Tiers
146 } else {
147 ProviderReasoningControl::EnabledDisabled
148 };
149
150 // What each requested tier actually becomes on the wire, straight from the
151 // route normalizer that shapes the real request.
152 //
153 // This subsumes a min/max floor-and-ceiling and expresses what one cannot:
154 // most non-Codex routes coerce `low` and `medium` to `high` while leaving
155 // `off` alone (first-party DeepSeek routes are the documented exception —
156 // their wire carries a real `low`), and an always-thinking route raises
157 // `off` instead. Reporting a `low` a route silently sends as `high` is
158 // the invisible substitution receipts exist to prevent, so the map — not
159 // a clamp — is the authority.
160 let wire_tiers = [
161 ReasoningEffort::Off,
162 ReasoningEffort::Low,
163 ReasoningEffort::Medium,
164 ReasoningEffort::High,
165 ReasoningEffort::Max,
166 ]
167 .map(|effort| {
168 tier_of(effort.normalize_for_route(provider, base_url, wire_model))
169 .unwrap_or(ReasoningTier::Off)
170 });
171
172 ReasoningCapability {
173 control,
174 min_tier: None,
175 max_tier: None,
176 wire_tiers: None,
177 }
178 .with_wire_tiers(wire_tiers)
179 }
180
181 fn tier_of(effort: ReasoningEffort) -> Option<ReasoningTier> {
182 match effort {
183 ReasoningEffort::Off => Some(ReasoningTier::Off),
184 ReasoningEffort::Minimal => Some(ReasoningTier::Low),
185 ReasoningEffort::Low => Some(ReasoningTier::Low),
186 ReasoningEffort::Medium => Some(ReasoningTier::Medium),
187 ReasoningEffort::High => Some(ReasoningTier::High),
188 ReasoningEffort::XHigh => Some(ReasoningTier::Max),
189 ReasoningEffort::Ultra => Some(ReasoningTier::Max),
190 ReasoningEffort::Max => Some(ReasoningTier::Max),
191 ReasoningEffort::Auto => None,
192 }
193 }
194
195 /// The **provider-facing** reasoning value for one tier on one exact route.
196 ///
197 /// A tier label (`off`, `max`) is a selector concept; what a request may carry
198 /// is a provider concept, and the two are not the same string. OpenAI Codex
199 /// routes spell the top tier `xhigh` and cannot express `off` at all, so
200 /// placing a bare tier label on a Codex request either sends a value the
201 /// provider does not accept or silently sends nothing and takes the provider
202 /// default while the receipt claims the tier. Reading the value back out of the
203 /// same route normalizer the client uses is what keeps the request and the
204 /// receipt describing each other.
205 #[must_use]
206 pub(crate) fn route_reasoning_setting(
207 provider: ApiProvider,
208 base_url: &str,
209 wire_model: &str,
210 tier: ReasoningTier,
211 ) -> String {
212 effort_of(tier)
213 .as_setting_for_route(provider, base_url, wire_model)
214 .to_string()
215 }
216
217 fn effort_of(tier: ReasoningTier) -> ReasoningEffort {
218 match tier {
219 ReasoningTier::Off => ReasoningEffort::Off,
220 ReasoningTier::Low => ReasoningEffort::Low,
221 ReasoningTier::Medium => ReasoningEffort::Medium,
222 ReasoningTier::High => ReasoningEffort::High,
223 ReasoningTier::Max => ReasoningEffort::Max,
224 }
225 }
226
227 /// Preflight one exact route: resolve the provider, canonicalize the model,
228 /// identify the endpoint, decide credential readiness **from local config**,
229 /// and derive the reasoning capability.
230 ///
231 /// No provider is contacted. Everything here is a configuration lookup, which
232 /// is what makes it safe to run before the operator's gates have fired.
233 pub(crate) fn preflight_route(
234 member_id: &str,
235 provider: &str,
236 model: &str,
237 config: &Config,
238 ) -> Result<PreflightedRoute, PreflightError> {
239 let identity = config
240 .resolve_provider_identity(provider.trim())
241 .map_err(|detail| PreflightError::ProviderUnresolved {
242 member: member_id.to_string(),
243 provider: provider.to_string(),
244 detail,
245 })?;
246
247 // The canonical wire model, resolved once. The receipt and the child spawn
248 // both read this value, so they cannot disagree about what actually ran.
249 let wire_model = crate::config::requested_model_for_provider(identity.provider, model.trim())
250 .ok_or_else(|| PreflightError::ModelUnresolved {
251 member: member_id.to_string(),
252 provider: identity.key.clone(),
253 model: model.to_string(),
254 detail: "not a known model for this provider".to_string(),
255 })?;
256 crate::config::validate_route(identity.provider, &wire_model).map_err(|detail| {
257 PreflightError::ModelUnresolved {
258 member: member_id.to_string(),
259 provider: identity.key.clone(),
260 model: wire_model.clone(),
261 detail,
262 }
263 })?;
264
265 let mut scoped = config.clone();
266 scoped.scope_to_provider_identity(&identity);
267 let base_url = scoped.active_route_base_url();
268
269 // Locally decided. A concrete loopback/self-hosted route is keyless by
270 // design, and that is a valid, first-class state — not a downgrade and
271 // not a missing credential. Ollama Cloud is hosted and falls through to
272 // the ordinary credential checks.
273 let credential =
274 if crate::config::provider_route_is_keyless_self_hosted(identity.provider, &base_url) {
275 CredentialReadiness::KeylessLocal
276 } else if crate::config::has_api_key_for(&scoped, identity.provider) {
277 CredentialReadiness::Configured
278 } else {
279 // The discriminant only. `Missing { detail }` names the provider table
280 // key, which for a custom route is the customer's own string.
281 codewhale_telemetry::session_counters()
282 .bump_error(codewhale_telemetry::ErrorCounter::AuthPreflightFailed);
283 CredentialReadiness::Missing {
284 detail: format!("no credential configured for `{}`", identity.key),
285 }
286 };
287
288 Ok(PreflightedRoute {
289 member_id: member_id.to_string(),
290 provider_id: identity.key.clone(),
291 provider_config_id: identity
292 .migrated_legacy_ollama_cloud_route
293 .then(|| provider.trim().to_string()),
294 provider_kind: if identity.provider == ApiProvider::OllamaCloud {
295 identity.provider.as_str().to_string()
296 } else {
297 format!("{:?}", identity.provider).to_ascii_lowercase()
298 },
299 declared_model: model.trim().to_string(),
300 wire_model: wire_model.clone(),
301 endpoint: EndpointIdentity::from_base_url(&base_url),
302 credential,
303 capability: reasoning_capability_for_route(identity.provider, &base_url, &wire_model),
304 })
305 }
306
307 /// Build the client one worker route would actually run on, and throw it away.
308 ///
309 /// Preflight resolves a route from *configuration*; this proves the same route
310 /// can be turned into a working client — the step that fails on a malformed
311 /// base URL, an unusable auth mode, or a transport CodeWhale cannot construct.
312 /// Doing it at Workflow start, for every member, is what stops a Fleet from
313 /// paying for a Router decision and only then discovering that the worker it
314 /// decided for could never have been launched.
315 ///
316 /// The client is deliberately not retained: the spawn path builds the child's
317 /// own client from the member's roster profile, and keeping a second one here
318 /// would create two objects that could drift apart.
319 fn validate_route_client(route: &PreflightedRoute, config: &Config) -> Result<(), String> {
320 let mut scoped = config.clone();
321 let identity = config.resolve_provider_identity(route.provider_config_id())?;
322 scoped.scope_to_provider_identity(&identity);
323 crate::client::CodewhaleClient::new(&scoped)
324 .map(|_| ())
325 .map_err(|error| {
326 format!(
327 "member `{}` is pinned to provider `{}` (model `{}`), whose client could not be \
328 built on this machine: {error}",
329 route.member_id, route.provider_id, route.wire_model
330 )
331 })
332 }
333
334 // ── The Reasoning Router, as a service ──────────────────────────────────────
335
336 /// The seam a Reasoning Router call goes through. Implemented live against the
337 /// provider client, and by a fixture in tests so the whole reasoning path is
338 /// exercised without a network.
339 #[async_trait]
340 pub(crate) trait FleetRouterCaller: Send + Sync + std::fmt::Debug {
341 /// Return the router's raw text response for one worker task.
342 async fn decide(&self, input: &RouterCallInput) -> Result<String, String>;
343
344 /// The Router service's exact identity, for the receipt.
345 fn identity(&self) -> RouterIdentity;
346 }
347
348 /// A Reasoning Router bound to its own exact preflighted route.
349 #[derive(Clone)]
350 pub(crate) struct LiveFleetRouter {
351 client: crate::client::CodewhaleClient,
352 captured: CapturedReasoningRouter,
353 route: PreflightedRoute,
354 /// The Router route's provider kind and base URL, kept so the call's
355 /// reasoning value can be shaped by the *actual* configured route rather
356 /// than by a generic tier label. Never serialized — the base URL can carry
357 /// a credential and receipts are durable.
358 provider: ApiProvider,
359 base_url: String,
360 /// What the Router call is actually made at, plus the four-sided disclosure
361 /// for the receipt. Configured by the operator (`off` or `low`), normalized
362 /// only against what the Router's own route can express.
363 call: RouterCallPlan,
364 }
365
366 impl std::fmt::Debug for LiveFleetRouter {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 f.debug_struct("LiveFleetRouter")
369 .field("router", &self.captured.qualified())
370 .field("provider", &self.route.provider_id)
371 .field("model", &self.route.wire_model)
372 .field("call_reasoning", &self.call.tier)
373 .field("client", &"<redacted>")
374 .finish()
375 }
376 }
377
378 impl LiveFleetRouter {
379 /// Resolve the Router service's exact configured route and build its client.
380 ///
381 /// A Router that cannot be resolved is an error here — at Workflow start,
382 /// before any worker is dispatched — not a silent downgrade to legacy
383 /// routing. Readiness is decided from local configuration; no live probe.
384 pub(crate) fn bind(
385 captured: &CapturedReasoningRouter,
386 config: &Config,
387 ) -> Result<Self, RouterBindError> {
388 let route = preflight_route(
389 &captured.id,
390 &captured.route.provider,
391 &captured.route.model,
392 config,
393 )
394 .map_err(|error| RouterBindError {
395 reason: error.to_string(),
396 })?;
397 route.require_ready().map_err(|error| RouterBindError {
398 reason: error.to_string(),
399 })?;
400
401 let identity = config
402 .resolve_provider_identity(route.provider_config_id())
403 .map_err(|detail| RouterBindError {
404 reason: format!(
405 "reasoning router provider `{}` did not resolve: {detail}",
406 route.provider_id
407 ),
408 })?;
409 let mut scoped = config.clone();
410 scoped.scope_to_provider_identity(&identity);
411 let base_url = scoped.active_route_base_url();
412 let client =
413 crate::client::CodewhaleClient::new(&scoped).map_err(|error| RouterBindError {
414 reason: format!(
415 "reasoning router provider `{}` client could not be built: {error}",
416 route.provider_id
417 ),
418 })?;
419
420 let call = router_call_plan(captured.requested_call_reasoning, &route.capability);
421
422 Ok(Self {
423 client,
424 captured: captured.clone(),
425 route,
426 provider: identity.provider,
427 base_url,
428 call,
429 })
430 }
431
432 /// The preflighted Router route, for cross-provider disclosure.
433 #[must_use]
434 pub(crate) fn route(&self) -> &PreflightedRoute {
435 &self.route
436 }
437 }
438
439 #[derive(Debug, Clone, PartialEq, Eq)]
440 pub(crate) struct RouterBindError {
441 pub(crate) reason: String,
442 }
443
444 #[async_trait]
445 impl FleetRouterCaller for LiveFleetRouter {
446 fn identity(&self) -> RouterIdentity {
447 RouterIdentity::from_captured(
448 &self.captured,
449 Some(&self.route),
450 Some(self.call.disclosure.clone()),
451 )
452 }
453
454 async fn decide(&self, input: &RouterCallInput) -> Result<String, String> {
455 use codewhale_models::{ContentBlock, Message, MessageRequest, SystemPrompt};
456
457 // The bounded, redacted summary is transmitted exactly once, in the
458 // user turn. The system prompt carries the contract and the frozen
459 // route, and no task content at all — sending it twice would double
460 // what leaves for this provider while the receipt counted one copy.
461 let request = MessageRequest {
462 model: self.route.wire_model.clone(),
463 messages: vec![Message {
464 role: Role::User,
465 content: vec![ContentBlock::Text {
466 text: router_user_message(input),
467 cache_control: None,
468 }],
469 }],
470 max_tokens: self
471 .client
472 .effective_max_output_tokens(&self.route.wire_model),
473 system: Some(SystemPrompt::Text(router_system_prompt(input))),
474 // A router receives no tools. Ever.
475 tools: None,
476 tool_choice: None,
477 metadata: None,
478 thinking: None,
479 // The operator-configured call tier remains authoritative. The
480 // normal route allowance above leaves room for its hidden
481 // reasoning before the small JSON answer is emitted.
482 reasoning_effort: Some(route_reasoning_setting(
483 self.provider,
484 &self.base_url,
485 &self.route.wire_model,
486 self.call.tier,
487 )),
488 stream: Some(false),
489 temperature: None,
490 top_p: None,
491 };
492
493 let response = self
494 .client
495 .create_message(request)
496 .await
497 .map_err(|error| error.to_string())?;
498 if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
499 return Err(format!(
500 "reasoning router response incomplete: provider stop reason `{}`",
501 codewhale_models::stop_reason_detail(response.stop_reason.as_deref())
502 ));
503 }
504 let text = response
505 .content
506 .into_iter()
507 .filter_map(|block| match block {
508 ContentBlock::Text { text, .. } => Some(text),
509 _ => None,
510 })
511 .collect::<Vec<_>>()
512 .join("");
513 if text.trim().is_empty() {
514 return Err("reasoning router returned an empty response".to_string());
515 }
516 Ok(text)
517 }
518 }
519
520 // ── The Workflow ───────────────────────────────────────────────────────────
521
522 /// An exact Fleet, frozen at Workflow start.
523 ///
524 /// The snapshot and the preflight are immutable for the life of the run:
525 /// editing `fleets/<name>.toml` afterwards changes only the next Workflow.
526 /// Durable runs and in-process spawns bind the same frozen member. The
527 /// in-process path projects only that member onto its existing profile binder;
528 /// it does not read or replace the currently selected Fleet.
529 #[derive(Clone)]
530 pub(crate) struct ExactFleetWorkflow {
531 snapshot: Arc<FleetSnapshot>,
532 preflight: Arc<RoutePreflight>,
533 router: Option<Arc<dyn FleetRouterCaller>>,
534 router_unavailable: Option<String>,
535 }
536
537 impl std::fmt::Debug for ExactFleetWorkflow {
538 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539 f.debug_struct("ExactFleetWorkflow")
540 .field("fleet", &self.snapshot.fleet().qualified())
541 .field("members", &self.snapshot.members().len())
542 .field("router", &self.router.is_some())
543 .finish()
544 }
545 }
546
547 /// One member, resolved and admitted — but **not yet routed**.
548 ///
549 /// This is the value the caller holds between admission and the Router call.
550 /// Producing it costs nothing: no provider is contacted, so a task that is
551 /// about to be rejected by a gate or blocked on capacity can be resolved
552 /// safely.
553 #[derive(Debug, Clone, PartialEq, Eq)]
554 pub(crate) struct ExactMemberBinding {
555 /// Canonical member id from the frozen snapshot.
556 pub(crate) member_id: String,
557 /// Semantic role — what gates, handoffs, and records use.
558 pub(crate) member_role: String,
559 /// The preflighted, frozen route.
560 pub(crate) route: PreflightedRoute,
561 /// Whether this member's reasoning comes from the Router.
562 pub(crate) requires_router: bool,
563 /// The clamped authority the child will actually run under.
564 pub(crate) authority: ChildAuthority,
565 /// The live session posture this binding was clamped against, kept so the
566 /// launch half can **recompute** the authority instead of trusting the copy
567 /// it was handed. A binding travels across an await point (gates, a
568 /// concurrency slot, a router call); recomputing is what makes a stale or
569 /// tampered authority detectable rather than merely improbable.
570 pub(crate) session: PermissionCeiling,
571 /// Source layer captured with the snapshot, never refreshed at spawn.
572 profile_origin: super::roster::ProfileOrigin,
573 task_allowed_tools: Option<Vec<String>>,
574 task_disallowed_tools: Vec<String>,
575 task_worktree_write: bool,
576 }
577
578 impl ExactMemberBinding {
579 /// Task options may narrow the Runtime/parent envelope, never replace the
580 /// frozen identity or grant authority. Preserve the narrowing for the
581 /// independent launch-time recomputation and its durable fingerprint.
582 pub(crate) fn narrow_for_task(
583 &mut self,
584 write_authority: Option<&str>,
585 allowed_tools: Option<&[String]>,
586 disallowed_tools: &[String],
587 max_depth: Option<u32>,
588 ) -> Result<(), String> {
589 match write_authority {
590 Some("read_only") => self.session.write = false,
591 Some("workspace_write" | "worktree_write") if !self.authority.ceiling.write => {
592 return Err(format!(
593 "member `{}` is read-only under its Runtime/parent ceiling; a task cannot request write authority via `write_authority`",
594 self.member_id,
595 ));
596 }
597 Some("workspace_write") if self.task_worktree_write => {
598 return Err("a task cannot remove its worktree isolation".to_string());
599 }
600 Some("worktree_write") => self.task_worktree_write = true,
601 Some("workspace_write") | None => {}
602 Some(other) => return Err(format!("invalid task `write_authority` value `{other}`")),
603 }
604 if let Some(depth) = max_depth {
605 self.session.delegation_depth = self.session.delegation_depth.min(depth);
606 }
607 if let Some(tools) = allowed_tools {
608 let mut tools = tools.to_vec();
609 if let Some(previous) = self.task_allowed_tools.as_ref() {
610 tools.retain(|tool| previous.contains(tool));
611 }
612 tools.sort();
613 tools.dedup();
614 self.task_allowed_tools = Some(tools);
615 }
616 self.task_disallowed_tools
617 .extend_from_slice(disallowed_tools);
618 self.task_disallowed_tools.sort();
619 self.task_disallowed_tools.dedup();
620 self.authority = self.recompute_authority(&self.member_role);
621 Ok(())
622 }
623
624 fn recompute_authority(&self, role: &str) -> ChildAuthority {
625 let mut authority = ChildAuthority::from_runtime_role(role, self.session);
626 if let Some(tools) = self.task_allowed_tools.as_ref() {
627 let mut tools = tools.clone();
628 if let Some(ceiling) = authority.allowed_tools.as_ref() {
629 tools.retain(|tool| ceiling.contains(tool));
630 }
631 authority.allowed_tools = Some(tools);
632 }
633 if !self.task_disallowed_tools.is_empty() {
634 authority
635 .disallowed_tools
636 .extend(self.task_disallowed_tools.iter().cloned());
637 authority.disallowed_tools.sort();
638 authority.disallowed_tools.dedup();
639 }
640 if authority.ceiling.write && self.task_worktree_write {
641 authority.write_authority = "worktree_write";
642 }
643 authority
644 }
645
646 /// Project one preflighted member into the existing profile binder. The
647 /// saved provider configuration key stays paired with the canonical wire
648 /// model (including compatibility-migrated provider identities).
649 pub(crate) fn spawn_profile(&self) -> super::profile::AgentProfile {
650 super::profile::AgentProfile {
651 id: self.member_id.clone(),
652 display_name: None,
653 description: None,
654 requires: Vec::new(),
655 profile: codewhale_config::FleetProfile {
656 slot: codewhale_config::FleetSlot::from_name(&self.member_role),
657 role: codewhale_config::FleetRole {
658 name: self.member_role.clone(),
659 ..Default::default()
660 },
661 provider: Some(self.route.provider_config_id().to_string()),
662 model: Some(self.route.wire_model.clone()),
663 ..Default::default()
664 },
665 // Snapshot identity is recorded on the Workflow receipt; profile
666 // application performs no source-file lookup.
667 source: std::path::PathBuf::new(),
668 origin: self.profile_origin,
669 plugin_authority: None,
670 }
671 }
672 }
673
674 /// What a launched exact member resolves to, after routing.
675 #[derive(Debug, Clone, PartialEq, Eq)]
676 pub(crate) struct ExactMemberLaunch {
677 /// Canonical member id; also the roster profile id the spawn resolves.
678 pub(crate) member_id: String,
679 /// Semantic role, preserved for gates/handoffs/records.
680 pub(crate) member_role: String,
681 /// Frozen provider id.
682 pub(crate) provider: String,
683 /// Canonical wire model — the same string the receipt records.
684 pub(crate) model: String,
685 /// Concrete reasoning setting label for the spawn request.
686 pub(crate) thinking: String,
687 /// The full requested → effective story, for the receipt.
688 pub(crate) reasoning: ResolvedReasoning,
689 /// The clamped authority the child runs under.
690 pub(crate) authority: ChildAuthority,
691 /// The durable, visible receipt for this launch.
692 pub(crate) receipt: FleetTaskReceipt,
693 }
694
695 impl ExactFleetWorkflow {
696 /// Capture a Workflow from a parsed exact Fleet document.
697 ///
698 /// Everything that can fail locally fails here, before any worker is
699 /// dispatched: an unresolvable provider, an unknown model, a missing
700 /// credential, an unresolvable Reasoning Router profile, or an `auto`
701 /// member with no usable Router.
702 pub(crate) fn capture(
703 document: &FleetDocument,
704 id: QualifiedFleetId,
705 captured_at: impl Into<String>,
706 config: Option<&Config>,
707 search_roots: &[FleetSearchRoot],
708 ) -> Result<Self, String> {
709 let exact = document
710 .exact()
711 .ok_or_else(|| "this Fleet is not an exact Fleet".to_string())?;
712
713 // Resolve the attached Reasoning Router *reference* into the one
714 // captured service both forms normalize onto.
715 let captured_router = match exact.router_ref() {
716 None => None,
717 Some(FleetRouterRef::LegacyInline(_)) => captured_legacy_inline_router(exact),
718 Some(FleetRouterRef::Profile { name }) => {
719 let (profile, router_id) =
720 ReasoningRouterProfile::load_by_name(&name, search_roots).map_err(|error| {
721 format!(
722 "exact Fleet `{}` references reasoning router `{name}`, which could \
723 not be loaded: {error}",
724 id.qualified()
725 )
726 })?;
727 Some(CapturedReasoningRouter::from_profile(
728 &profile,
729 router_id.origin,
730 ))
731 }
732 };
733
734 // Capture, then immediately verify the hash the receipt will vouch for.
735 // `capture` computes it, so this can only fail if the value took a
736 // detour through `Deserialize` — but that is exactly the case a receipt
737 // must not certify, and checking here means no later caller has to
738 // remember to.
739 let snapshot = FleetSnapshot::capture(id, document, captured_at, captured_router.clone())
740 .and_then(FleetSnapshot::into_verified)
741 .map_err(|error| error.to_string())?;
742
743 // Preflight every worker route before anything else can happen.
744 let (preflight, router) = Self::preflight_and_bind(&snapshot, captured_router, config)?;
745
746 let router_unavailable = match (snapshot.router(), &router) {
747 (Some(_), None) => {
748 Some("the Fleet's reasoning router could not be bound on this machine".to_string())
749 }
750 _ => None,
751 };
752
753 let workflow = Self {
754 snapshot: Arc::new(snapshot),
755 preflight: Arc::new(preflight),
756 router,
757 router_unavailable,
758 };
759 workflow.reject_unusable_auto_members()?;
760 Ok(workflow)
761 }
762
763 /// Preflight every worker route and bind the Router, or fail the start.
764 fn preflight_and_bind(
765 snapshot: &FleetSnapshot,
766 captured_router: Option<CapturedReasoningRouter>,
767 config: Option<&Config>,
768 ) -> Result<(RoutePreflight, Option<Arc<dyn FleetRouterCaller>>), String> {
769 let Some(config) = config else {
770 return Err(format!(
771 "exact Fleet `{}` cannot start: no session config is available to preflight its \
772 members' providers and models. An exact Fleet fails closed here rather than \
773 dispatching a worker onto a route it never verified.",
774 snapshot.fleet().qualified()
775 ));
776 };
777
778 let mut workers = Vec::with_capacity(snapshot.members().len());
779 for member in snapshot.members() {
780 let route = preflight_route(
781 &member.id,
782 &member.route.provider,
783 &member.route.model,
784 config,
785 )
786 .map_err(|error| {
787 format!(
788 "exact Fleet `{}` cannot start: {error}",
789 snapshot.fleet().qualified()
790 )
791 })?;
792 route.require_ready().map_err(|error| {
793 format!(
794 "exact Fleet `{}` cannot start: {error}",
795 snapshot.fleet().qualified()
796 )
797 })?;
798 workers.push(route);
799 }
800
801 // Every worker client is constructed and validated **before** the
802 // Router is bound, let alone called. A member whose client cannot be
803 // built is a start-time failure; discovering it after a Router decision
804 // means the operator paid for a routing request for a task that could
805 // never have run.
806 for route in &workers {
807 validate_route_client(route, config).map_err(|error| {
808 format!(
809 "exact Fleet `{}` cannot start: {error}",
810 snapshot.fleet().qualified()
811 )
812 })?;
813 }
814
815 let mut router: Option<Arc<dyn FleetRouterCaller>> = None;
816 let mut router_route = None;
817 if let Some(captured) = &captured_router {
818 match LiveFleetRouter::bind(captured, config) {
819 Ok(live) => {
820 router_route = Some(live.route().clone());
821 router = Some(Arc::new(live));
822 }
823 Err(error) => {
824 // Recorded rather than raised: a Fleet with no `auto`
825 // member does not need its router to be usable, and
826 // failing the whole Workflow for an unused service would
827 // be the wrong trade.
828 if snapshot.has_auto_member() {
829 return Err(format!(
830 "exact Fleet `{}` cannot start: member(s) {} request reasoning \
831 `auto` but the Fleet's reasoning router is unusable ({}). Fix the \
832 router profile or pin an explicit reasoning tier — exact Fleets \
833 never fall back to legacy model routing or a local heuristic.",
834 snapshot.fleet().qualified(),
835 snapshot.auto_member_ids().join(", "),
836 error.reason,
837 ));
838 }
839 }
840 }
841 }
842
843 Ok((RoutePreflight::new(workers, router_route), router))
844 }
845
846 /// Fail at Workflow start — not at task launch — when a member requests
847 /// `auto` and the Fleet has no Router it can actually call.
848 fn reject_unusable_auto_members(&self) -> Result<(), String> {
849 if !self.snapshot.has_auto_member() || self.router.is_some() {
850 return Ok(());
851 }
852 let reason = self
853 .router_unavailable
854 .clone()
855 .unwrap_or_else(|| "this Fleet references no reasoning router".to_string());
856 Err(format!(
857 "exact Fleet `{}` cannot start: member(s) {} request reasoning `auto` but the Fleet's \
858 reasoning router is unusable ({reason}). Attach a working reasoning router or pin an \
859 explicit reasoning tier — exact Fleets never fall back to legacy model routing or a \
860 local heuristic.",
861 self.snapshot.fleet().qualified(),
862 self.snapshot.auto_member_ids().join(", "),
863 ))
864 }
865
866 #[must_use]
867 pub(crate) fn snapshot(&self) -> &Arc<FleetSnapshot> {
868 &self.snapshot
869 }
870
871 /// Human-readable roster listing for "unknown member" errors.
872 #[must_use]
873 pub(crate) fn member_names(&self) -> String {
874 self.snapshot
875 .members()
876 .iter()
877 .map(|member| {
878 if member.role == member.id {
879 member.id.clone()
880 } else {
881 format!("{} (role {})", member.id, member.role)
882 }
883 })
884 .collect::<Vec<_>>()
885 .join(", ")
886 }
887
888 /// Resolve a task's `role`/`profile` to one admitted member, **without
889 /// contacting any provider**.
890 ///
891 /// This is deliberately the cheap half of a launch. It runs before gate
892 /// evaluation and before a concurrency slot is taken, so a task that is
893 /// about to be rejected or queued costs nothing and discloses nothing.
894 ///
895 /// A task that names both a `profile` and a `role` which resolve to
896 /// different members is **rejected**, not silently resolved by precedence:
897 /// the two fields would then disagree about who ran, and the receipt could
898 /// only record one of them.
899 pub(crate) fn bind_member(
900 &self,
901 profile: Option<&str>,
902 role: Option<&str>,
903 session: PermissionCeiling,
904 ) -> Result<ExactMemberBinding, String> {
905 let fleet = self.snapshot.fleet().qualified();
906 let profile = profile.map(str::trim).filter(|key| !key.is_empty());
907 let role = role.map(str::trim).filter(|key| !key.is_empty());
908
909 let member = match (profile, role) {
910 (None, None) => {
911 return Err(format!(
912 "Fleet `{fleet}` is an exact Fleet: every task must name a member via `role` \
913 or `profile`. Members: {}",
914 self.member_names()
915 ));
916 }
917 (Some(profile), None) => self.lookup(profile)?,
918 (None, Some(role)) => self.lookup(role)?,
919 (Some(profile), Some(role)) => {
920 let by_profile = self.lookup(profile)?;
921 let by_role = self.lookup(role)?;
922 if by_profile.id != by_role.id {
923 return Err(format!(
924 "Fleet `{fleet}`: task names profile `{profile}` (member `{}`) and role \
925 `{role}` (member `{}`), which are different members. A task must name \
926 one member; the two fields cannot disagree about who ran.",
927 by_profile.id, by_role.id
928 ));
929 }
930 by_profile
931 }
932 };
933
934 let route = self.preflight.worker(&member.id).ok_or_else(|| {
935 format!(
936 "Fleet `{fleet}`: member `{}` has no preflighted route",
937 member.id
938 )
939 })?;
940
941 Ok(ExactMemberBinding {
942 member_id: member.id.clone(),
943 member_role: public_role_label(&member.role),
944 route: route.clone(),
945 requires_router: member.requested_reasoning.is_auto(),
946 authority: ChildAuthority::from_runtime_role(&member.role, session),
947 session,
948 profile_origin: match self.snapshot.fleet().origin.as_str() {
949 "workspace" => super::roster::ProfileOrigin::Workspace,
950 "codewhale_home" => super::roster::ProfileOrigin::Personal,
951 _ => super::roster::ProfileOrigin::Config,
952 },
953 task_allowed_tools: None,
954 task_disallowed_tools: Vec::new(),
955 task_worktree_write: false,
956 })
957 }
958
959 fn lookup(&self, key: &str) -> Result<&FleetSnapshotMember, String> {
960 self.snapshot.member_by_id_or_role(key).ok_or_else(|| {
961 format!(
962 "unknown exact Fleet member `{key}` in `{}`. Members: {}",
963 self.snapshot.fleet().qualified(),
964 self.member_names()
965 )
966 })
967 }
968
969 /// Finish an **already admitted** binding: decide only how hard the already
970 /// frozen model thinks, then build the receipt.
971 ///
972 /// This is the half that can cost money. Calling it means the task has
973 /// already passed its gates and holds a concurrency slot.
974 pub(crate) async fn route_admitted_task(
975 &self,
976 binding: &ExactMemberBinding,
977 task_summary: &str,
978 ) -> Result<ExactMemberLaunch, String> {
979 // The receipt built at the end of this function stamps
980 // `snapshot.content_hash()` as evidence that this launch matched a saved
981 // definition. Verify the hash actually describes the snapshot *before*
982 // spending a router call or emitting that claim — an unverified hash is
983 // not weaker evidence, it is a false receipt.
984 self.snapshot
985 .verify_content_hash()
986 .map_err(|error| error.to_string())?;
987
988 let member = self.snapshot.member(&binding.member_id).ok_or_else(|| {
989 format!(
990 "Fleet `{}`: member `{}` vanished between admission and launch",
991 self.snapshot.fleet().qualified(),
992 binding.member_id
993 )
994 })?;
995
996 // Recompute authority from Runtime's role policy and the live-parent
997 // posture this binding was admitted against, and require it to be
998 // *identical* to the one the binding carries. The snapshot supplies
999 // identity only; legacy internal `FleetProfilePermissions` input is never
1000 // consulted.
1001 //
1002 // A binding crosses gates, a concurrency wait, and (for `auto` members)
1003 // a router call before it gets here, so "the authority I was handed" and
1004 // "the authority this member actually has" are two different claims. The
1005 // launch below is the value the spawn path consumes, so it must be the
1006 // recomputed one; the equality check is what turns a divergence into a
1007 // refused launch instead of a silently widened child.
1008 let authority = binding.recompute_authority(&member.role);
1009 if authority != binding.authority {
1010 return Err(format!(
1011 "Fleet `{}`: member `{}` resolved a different permission envelope at launch than \
1012 at admission, so the launch is refused. admitted={} launched={}",
1013 self.snapshot.fleet().qualified(),
1014 binding.member_id,
1015 binding.authority.fingerprint(),
1016 authority.fingerprint(),
1017 ));
1018 }
1019
1020 // The route is already frozen and preflighted. Nothing below may move
1021 // it — not a task option, not the Router.
1022 let frozen = binding.route.frozen();
1023 let capability = binding.route.capability;
1024
1025 let availability = self.router_availability();
1026 let mut router_identity = None;
1027 let mut routing_summary: Option<RoutingDisclosure> = None;
1028 let decision = if binding.requires_router {
1029 let router = self.router.as_ref().ok_or_else(|| {
1030 format!(
1031 "member `{}` requests reasoning `auto` but Fleet `{}` has no usable reasoning \
1032 router",
1033 binding.member_id,
1034 self.snapshot.fleet().qualified()
1035 )
1036 })?;
1037 let cross_provider = self.preflight.crosses_providers(&binding.member_id);
1038 let payload = bounded_routing_payload(task_summary).with_cross_provider(cross_provider);
1039 // What actually leaves for the router's provider, recorded so the
1040 // receipt discloses it — counts and hash only, never the text.
1041 routing_summary = Some(payload.disclosure().clone());
1042 router_identity = Some(router.identity());
1043 let input = RouterCallInput {
1044 fleet: self.snapshot.fleet().qualified(),
1045 member_id: binding.member_id.clone(),
1046 frozen: frozen.clone(),
1047 payload,
1048 };
1049 let raw = router.decide(&input).await.map_err(|error| {
1050 format!(
1051 "reasoning router call failed for member `{}`: {error}",
1052 binding.member_id
1053 )
1054 })?;
1055 Some(parse_router_decision(&raw).map_err(|error| {
1056 format!(
1057 "reasoning router returned an unusable decision for member `{}`: {error}",
1058 binding.member_id
1059 )
1060 })?)
1061 } else {
1062 None
1063 };
1064
1065 let reasoning = resolve_exact_member_reasoning(
1066 &binding.member_id,
1067 &frozen,
1068 member.requested_reasoning,
1069 &capability,
1070 &availability,
1071 decision.as_ref(),
1072 router_identity.as_ref(),
1073 )
1074 .map_err(|error| error.to_string())?;
1075
1076 // Every exact launch carries a concrete tier. `auto` is resolved by the
1077 // router above and the literal sentinel never leaves this function.
1078 //
1079 // `NativeAdaptive` is no longer reachable here: removing the bypass
1080 // (so `auto` always asks the router) also removed the one path that
1081 // produced it. It used to be launched as `off`, which mislabelled the
1082 // request — a route choosing its own depth is not a route with thinking
1083 // disabled. Rather than re-introduce that lie, this fails loudly if the
1084 // variant ever comes back.
1085 let thinking = match reasoning.effective() {
1086 EffectiveReasoning::Tier(tier) => effort_of(tier).as_setting().to_string(),
1087 EffectiveReasoning::NativeAdaptive => {
1088 return Err(format!(
1089 "member `{}` resolved to provider-native adaptive reasoning, which an exact \
1090 Fleet launch cannot place on a request. Pin an explicit reasoning tier.",
1091 binding.member_id
1092 ));
1093 }
1094 };
1095
1096 // The durable receipt. Built here, at the one place that knows every
1097 // side of the decision, so no consumer has to re-derive it.
1098 let receipt = FleetTaskReceipt::new(
1099 self.snapshot.fleet().qualified(),
1100 self.snapshot.schema_kind(),
1101 self.snapshot.schema_revision(),
1102 self.snapshot.content_hash(),
1103 binding.member_id.clone(),
1104 binding.member_role.clone(),
1105 &binding.route,
1106 &reasoning,
1107 routing_summary,
1108 binding.authority.ceiling.network_tool,
1109 )
1110 // The fingerprint of the envelope this launch installs, carried on the
1111 // durable receipt so the spawn boundary has something to check against
1112 // rather than a sentinel it can only assume.
1113 .with_authority_fingerprint(authority.fingerprint())
1114 // Semantic role and runtime posture stay two separate facts all the way
1115 // onto the durable receipt: `member_role` is what the operator named
1116 // and what gates key on, `posture_role` is the Runtime baseline role.
1117 // The fingerprint above records the effective parent-narrowed surface.
1118 .with_posture_role(binding.authority.posture_role);
1119
1120 Ok(ExactMemberLaunch {
1121 member_id: binding.member_id.clone(),
1122 member_role: binding.member_role.clone(),
1123 provider: frozen.provider,
1124 model: frozen.model,
1125 thinking,
1126 reasoning,
1127 authority,
1128 receipt,
1129 })
1130 }
1131
1132 fn router_availability(&self) -> RouterAvailability {
1133 match (&self.router, &self.router_unavailable) {
1134 (Some(_), _) => RouterAvailability::Ready,
1135 (None, Some(reason)) => RouterAvailability::Unavailable {
1136 reason: reason.clone(),
1137 },
1138 (None, None) => RouterAvailability::Absent,
1139 }
1140 }
1141 }
1142
1143 // ── Test seams ──────────────────────────────────────────────────────────────
1144
1145 /// A Router that answers with a fixed fixture string, recording what it saw.
1146 ///
1147 /// Test-only: it is how the exact-Fleet reasoning path is exercised end to end
1148 /// without a provider call, and how "the router was never called" is asserted.
1149 #[cfg(test)]
1150 #[derive(Debug)]
1151 pub(crate) struct StaticFleetRouter {
1152 response: String,
1153 identity: RouterIdentity,
1154 pub(crate) seen: std::sync::Mutex<Vec<RouterCallInput>>,
1155 }
1156
1157 #[cfg(test)]
1158 impl StaticFleetRouter {
1159 pub(crate) fn new(response: impl Into<String>) -> Arc<Self> {
1160 Arc::new(Self {
1161 response: response.into(),
1162 identity: RouterIdentity {
1163 id: "luna-low".to_string(),
1164 origin: "workspace".to_string(),
1165 service_kind: codewhale_workflow::REASONING_ROUTER_SERVICE_KIND.to_string(),
1166 legacy_inline: false,
1167 provider: "openai".to_string(),
1168 model: "gpt-5.6-luna".to_string(),
1169 endpoint: Some(EndpointIdentity::from_base_url("https://api.openai.com/v1")),
1170 call: Some(
1171 router_call_plan(
1172 codewhale_workflow::RouterCallReasoning::Low,
1173 &ReasoningCapability::tiered(),
1174 )
1175 .disclosure,
1176 ),
1177 },
1178 seen: std::sync::Mutex::new(Vec::new()),
1179 })
1180 }
1181
1182 /// How many router calls were made. Zero is the assertion that matters for
1183 /// manual reasoning and for rejected/blocked tasks.
1184 pub(crate) fn call_count(&self) -> usize {
1185 self.seen.lock().expect("router log").len()
1186 }
1187 }
1188
1189 #[cfg(test)]
1190 #[async_trait]
1191 impl FleetRouterCaller for StaticFleetRouter {
1192 fn identity(&self) -> RouterIdentity {
1193 self.identity.clone()
1194 }
1195
1196 async fn decide(&self, input: &RouterCallInput) -> Result<String, String> {
1197 self.seen.lock().expect("router log").push(input.clone());
1198 Ok(self.response.clone())
1199 }
1200 }
1201
1202 #[cfg(test)]
1203 impl ExactFleetWorkflow {
1204 /// Build a Workflow with an injected Router and a supplied capability,
1205 /// skipping provider binding so the reasoning path runs with no network and
1206 /// no configured provider.
1207 /// Takes the concrete fixture type rather than `Option<Arc<dyn ...>>`:
1208 /// `Option` does not coerce its payload, so the unsizing is done once here
1209 /// instead of at every call site.
1210 pub(crate) fn for_tests(
1211 document: &FleetDocument,
1212 id: QualifiedFleetId,
1213 router: Option<Arc<StaticFleetRouter>>,
1214 ) -> Self {
1215 Self::for_tests_with_capability(document, id, router, ReasoningCapability::tiered())
1216 }
1217
1218 pub(crate) fn for_tests_with_capability(
1219 document: &FleetDocument,
1220 id: QualifiedFleetId,
1221 router: Option<Arc<StaticFleetRouter>>,
1222 capability: ReasoningCapability,
1223 ) -> Self {
1224 let exact = document.exact().expect("exact Fleet");
1225 let captured = captured_legacy_inline_router(exact).or_else(|| {
1226 exact.reasoning_router.as_ref().map(|name| {
1227 CapturedReasoningRouter::from_profile(
1228 &ReasoningRouterProfile::parse(&format!(
1229 "name = \"{name}\"\nschema = \"reasoning_router\"\nprovider = \
1230 \"openai\"\nmodel = \"gpt-5.6-luna\"\ncall_reasoning = \"low\"\n"
1231 ))
1232 .expect("router profile"),
1233 "workspace",
1234 )
1235 })
1236 });
1237 let snapshot =
1238 FleetSnapshot::capture(id, document, "2026-07-26T00:00:00Z", captured.clone())
1239 .expect("valid roster");
1240
1241 let workers = snapshot
1242 .members()
1243 .iter()
1244 .map(|member| {
1245 test_route(
1246 &member.id,
1247 &member.route.provider,
1248 &member.route.model,
1249 capability,
1250 )
1251 })
1252 .collect::<Vec<_>>();
1253 let router_route = captured.as_ref().map(|captured| {
1254 test_route(
1255 "router",
1256 &captured.route.provider,
1257 &captured.route.model,
1258 capability,
1259 )
1260 });
1261 let preflight = RoutePreflight::new(workers, router_route);
1262
1263 Self {
1264 snapshot: Arc::new(snapshot),
1265 preflight: Arc::new(preflight),
1266 router: router.map(|router| {
1267 let router: Arc<dyn FleetRouterCaller> = router;
1268 router
1269 }),
1270 router_unavailable: None,
1271 }
1272 }
1273
1274 /// A Workflow whose Router failed to bind locally — the shape
1275 /// [`Self::capture`] produces when a Router's provider has no credentials
1276 /// configured on this machine. No network is involved either way.
1277 pub(crate) fn for_tests_with_unavailable_router(
1278 document: &FleetDocument,
1279 id: QualifiedFleetId,
1280 reason: &str,
1281 ) -> Result<Self, String> {
1282 let mut workflow = Self::for_tests(document, id, None);
1283 workflow.router_unavailable = Some(reason.to_string());
1284 workflow.reject_unusable_auto_members()?;
1285 Ok(workflow)
1286 }
1287 }
1288
1289 #[cfg(test)]
1290 fn test_route(
1291 member: &str,
1292 provider: &str,
1293 model: &str,
1294 capability: ReasoningCapability,
1295 ) -> PreflightedRoute {
1296 PreflightedRoute {
1297 member_id: member.to_string(),
1298 provider_id: provider.to_string(),
1299 provider_config_id: None,
1300 provider_kind: provider.to_string(),
1301 declared_model: model.to_string(),
1302 wire_model: model.to_string(),
1303 endpoint: EndpointIdentity::from_base_url("https://api.example.test/v1"),
1304 credential: CredentialReadiness::Configured,
1305 capability,
1306 }
1307 }
1308
1309 #[cfg(test)]
1310 mod shell_ceiling_tests {
1311 use super::*;
1312
1313 fn ceiling(write: bool, shell: ShellCeiling) -> PermissionCeiling {
1314 PermissionCeiling {
1315 write,
1316 network_tool: false,
1317 shell,
1318 delegation_depth: 0,
1319 tools: true,
1320 }
1321 }
1322
1323 fn session() -> PermissionCeiling {
1324 ceiling(true, ShellCeiling::Full)
1325 }
1326
1327 fn denies_raw_shell(authority: &ChildAuthority) -> bool {
1328 authority
1329 .disallowed_tools
1330 .iter()
1331 .any(|rule| rule == RAW_SHELL_SENTINEL)
1332 }
1333
1334 /// The `analyst` preset grants no shell. The envelope reads its shell bit
1335 /// back off the deny list, so the denial has to actually be installed —
1336 /// otherwise a shell-less ceiling reaches dispatch claiming full shell
1337 /// authority and can start a verification process.
1338 #[test]
1339 fn a_shell_less_ceiling_installs_the_raw_shell_denial() {
1340 for shell in [ShellCeiling::None, ShellCeiling::ReadOnly] {
1341 let authority = ChildAuthority::clamp(ceiling(false, shell), session());
1342 assert!(
1343 denies_raw_shell(&authority),
1344 "{shell:?} must deny raw shell"
1345 );
1346 }
1347 }
1348
1349 /// The gap this repair closed: a write-capable member inside a session with
1350 /// no shell authority clamps to `write = true, shell = none`. Keying the
1351 /// denial on `write` alone left that combination with no denial installed —
1352 /// and therefore with an envelope that claimed shell authority the ceiling
1353 /// had refused.
1354 #[test]
1355 fn a_write_capable_member_clamped_to_no_shell_still_loses_raw_shell() {
1356 let authority = ChildAuthority::clamp(
1357 ceiling(true, ShellCeiling::Full),
1358 ceiling(true, ShellCeiling::None),
1359 );
1360
1361 assert_eq!(authority.ceiling.shell, ShellCeiling::None);
1362 assert!(authority.ceiling.write, "the write half is unchanged");
1363 assert!(denies_raw_shell(&authority));
1364 }
1365
1366 /// Prior behavior preserved: a `verifier`/`tester` ceiling
1367 /// (`write = false, shell = "full"`) still loses raw shell, and a fully
1368 /// write-capable member still keeps it.
1369 #[test]
1370 fn the_existing_verifier_and_full_ceilings_are_unchanged() {
1371 let verifier = ChildAuthority::clamp(ceiling(false, ShellCeiling::Full), session());
1372 assert!(denies_raw_shell(&verifier));
1373 assert_eq!(verifier.posture_role, "test");
1374
1375 let full = ChildAuthority::clamp(ceiling(true, ShellCeiling::Full), session());
1376 assert!(!denies_raw_shell(&full));
1377 assert_eq!(full.posture_role, "implement");
1378 }
1379
1380 #[test]
1381 fn bounded_inspection_role_keeps_only_classifier_bounded_bash() {
1382 for role in ["scout", "reviewer", "planner"] {
1383 let authority = ChildAuthority::from_runtime_role(role, session());
1384 assert!(
1385 !authority
1386 .disallowed_tools
1387 .iter()
1388 .any(|name| name.eq_ignore_ascii_case("Bash")),
1389 "{role} keeps canonical Bash for per-input classification"
1390 );
1391 for denied in [
1392 "exec_shell",
1393 "task_shell_start",
1394 "task_shell_wait",
1395 "terminal/*",
1396 "write_file",
1397 "apply_patch",
1398 ] {
1399 assert!(
1400 authority.disallowed_tools.iter().any(|name| name == denied),
1401 "{role} must still deny {denied}: {:?}",
1402 authority.disallowed_tools
1403 );
1404 }
1405 }
1406
1407 for role in ["consultant", "verifier"] {
1408 let authority = ChildAuthority::from_runtime_role(role, session());
1409 assert!(
1410 authority
1411 .disallowed_tools
1412 .iter()
1413 .any(|name| name.eq_ignore_ascii_case("Bash")),
1414 "{role} must not gain the read-only inspection exception"
1415 );
1416 }
1417
1418 let parent_shell_off =
1419 ChildAuthority::from_runtime_role("scout", ceiling(true, ShellCeiling::None));
1420 assert!(
1421 parent_shell_off
1422 .disallowed_tools
1423 .iter()
1424 .any(|name| name.eq_ignore_ascii_case("Bash")),
1425 "a named Scout may not turn a parent shell-off ceiling into ReadOnly"
1426 );
1427 let planner_parent_shell_off =
1428 ChildAuthority::from_runtime_role("planner", ceiling(true, ShellCeiling::None));
1429 assert!(
1430 planner_parent_shell_off
1431 .disallowed_tools
1432 .iter()
1433 .any(|name| name.eq_ignore_ascii_case("Bash")),
1434 "a named planner may not turn a parent shell-off ceiling into ReadOnly"
1435 );
1436 assert_eq!(planner_parent_shell_off.posture_role, "planner");
1437 assert_eq!(
1438 session_shell_ceiling(crate::worker_profile::ShellPolicy::Full, false),
1439 ShellCeiling::None
1440 );
1441 }
1442
1443 /// #5426 acceptance 2, made mechanical: delegation moves work, never
1444 /// authority. A read-only scout's own runtime posture is the "session"
1445 /// its children clamp against, so a Runtime `builder` dispatched from a
1446 /// read-only parent lands read-only — raw shell gone and mutating tools
1447 /// denied — while the Runtime posture remains separately identified and delegation stays
1448 /// available (the depth budget is the parent's, not zero). The escape
1449 /// hatch is work capacity, never a wider envelope.
1450 #[test]
1451 fn a_read_only_parents_delegation_never_widens_authority() {
1452 // The scout's live runtime posture, expressed as the session ceiling
1453 // a child clamps against: no writes, read-only shell, network kept,
1454 // one level of delegation budget left.
1455 let scout_runtime = PermissionCeiling {
1456 write: false,
1457 network_tool: true,
1458 shell: ShellCeiling::ReadOnly,
1459 delegation_depth: 1,
1460 tools: true,
1461 };
1462 let authority = ChildAuthority::from_runtime_role("builder", scout_runtime);
1463
1464 // Authority does not widen through delegation: the child is read-only.
1465 assert!(!authority.ceiling.write);
1466 assert_eq!(authority.ceiling.shell, ShellCeiling::ReadOnly);
1467 assert_eq!(authority.write_authority, "read_only");
1468 assert_eq!(authority.posture_role, "implement");
1469 assert!(denies_raw_shell(&authority));
1470 for mutating in ["write_file", "apply_patch"] {
1471 assert!(
1472 authority
1473 .disallowed_tools
1474 .iter()
1475 .any(|rule| rule == mutating),
1476 "{mutating} must stay denied for a scout-delegated builder: {:?}",
1477 authority.disallowed_tools
1478 );
1479 }
1480
1481 // The escape hatch itself stays open: delegation is still possible
1482 // (the parent's budget is intact). But it is useless for shell:
1483 // canonical Bash is denied to a delegated child (it is not a bounded
1484 // inspection role), so a scout can never obtain bash by spawning —
1485 // the scout's own bounded read-only Bash from #5428 is the only shell
1486 // path a read-only parent has.
1487 assert_eq!(authority.max_depth, 1);
1488 assert!(
1489 authority
1490 .disallowed_tools
1491 .iter()
1492 .any(|name| name.eq_ignore_ascii_case("Bash")),
1493 "a scout-delegated child must not gain canonical Bash: {:?}",
1494 authority.disallowed_tools
1495 );
1496 }
1497
1498 /// The deny list feeds the fingerprint, so a ceiling that now denies more
1499 /// must fingerprint differently from one that does not. Two postures that
1500 /// install different surfaces may never share a fingerprint.
1501 #[test]
1502 fn the_shell_denial_is_visible_in_the_fingerprint() {
1503 let no_shell = ChildAuthority::clamp(ceiling(false, ShellCeiling::None), session());
1504 let full = ChildAuthority::clamp(ceiling(true, ShellCeiling::Full), session());
1505
1506 assert_ne!(no_shell.fingerprint(), full.fingerprint());
1507 assert!(no_shell.fingerprint().contains("shell=none"));
1508 }
1509
1510 /// Every rule the shell clamp installs is a *posture* denial, so a
1511 /// grandchild spawned with `inherit_disallowed_tools: false` cannot drop it.
1512 #[test]
1513 fn the_installed_shell_denials_are_posture_denials() {
1514 let authority = ChildAuthority::clamp(ceiling(false, ShellCeiling::None), session());
1515 for rule in &authority.disallowed_tools {
1516 assert!(is_posture_denial(rule), "{rule} must be a posture denial");
1517 }
1518 }
1519 }
1520
1521 #[cfg(test)]
1522 mod tests {
1523 use super::*;
1524 use codewhale_workflow::{
1525 EffectiveReasoningSource, ProviderEffectiveReasoning, RequestedReasoning,
1526 };
1527
1528 /// A Fleet that references a saved, reusable Reasoning Router service.
1529 const GLM_FLEET: &str = r#"
1530 name = "glm-pair"
1531 schema = "exact"
1532 reasoning_router = "luna-low"
1533
1534 [[members]]
1535 id = "implementer"
1536 role = "builder"
1537 provider = "zai"
1538 model = "glm-5"
1539 reasoning = "auto"
1540 permissions = "read_write"
1541
1542 [[members]]
1543 id = "auditor"
1544 role = "reviewer"
1545 provider = "zai"
1546 model = "glm-5"
1547 reasoning = "high"
1548 permissions = "read_only"
1549 "#;
1550
1551 fn id() -> QualifiedFleetId {
1552 QualifiedFleetId {
1553 name: "glm-pair".to_string(),
1554 origin: "workspace".to_string(),
1555 }
1556 }
1557
1558 fn full_session() -> PermissionCeiling {
1559 PermissionCeiling {
1560 write: true,
1561 network_tool: true,
1562 shell: ShellCeiling::Full,
1563 delegation_depth: codewhale_config::DEFAULT_SPAWN_DEPTH,
1564 tools: true,
1565 }
1566 }
1567
1568 /// Takes the concrete fixture type: `Option` does not coerce its payload,
1569 /// so the unsizing to `Arc<dyn FleetRouterCaller>` is spelled out here once
1570 /// rather than at every call site.
1571 fn workflow_with(router: Option<Arc<StaticFleetRouter>>, text: &str) -> ExactFleetWorkflow {
1572 let document = FleetDocument::parse(text).expect("parse");
1573 ExactFleetWorkflow::for_tests(&document, id(), router)
1574 }
1575
1576 #[tokio::test]
1577 async fn an_auto_member_takes_a_reasoning_only_router_decision_on_a_frozen_route() {
1578 let router = StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
1579 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
1580
1581 let binding = workflow
1582 .bind_member(None, Some("builder"), full_session())
1583 .expect("role resolves");
1584 assert_eq!(
1585 router.call_count(),
1586 0,
1587 "binding a member must not cost a router call"
1588 );
1589
1590 let launch = workflow
1591 .route_admitted_task(&binding, "refactor three crates")
1592 .await
1593 .expect("auto resolves through the router");
1594
1595 // The route did not move.
1596 assert_eq!(launch.provider, "zai");
1597 assert_eq!(launch.model, "glm-5");
1598 assert_eq!(launch.thinking, "max");
1599 assert_eq!(launch.member_id, "implementer");
1600 assert_eq!(launch.member_role, "implement");
1601 assert_eq!(launch.reasoning.requested(), RequestedReasoning::Auto);
1602 assert_eq!(
1603 launch.reasoning.source(),
1604 EffectiveReasoningSource::FleetRouter
1605 );
1606
1607 // The router saw the frozen route as context, never as a question, and
1608 // received the bounded payload rather than the raw task.
1609 let seen = router.seen.lock().expect("log");
1610 assert_eq!(seen.len(), 1);
1611 assert_eq!(seen[0].frozen.model, "glm-5");
1612 assert_eq!(seen[0].member_id, "implementer");
1613 assert_eq!(seen[0].payload.text(), "refactor three crates");
1614 }
1615
1616 /// The semantic role must survive onto the launch and the receipt: a gate
1617 /// or handoff keyed on `builder` has to still see `builder` even though the
1618 /// roster resolves the distinct profile id `implementer`.
1619 #[tokio::test]
1620 async fn the_semantic_role_survives_while_the_id_addresses_the_roster() {
1621 let workflow = workflow_with(
1622 Some(StaticFleetRouter::new(r#"{"reasoning":"low"}"#)),
1623 GLM_FLEET,
1624 );
1625
1626 let binding = workflow
1627 .bind_member(None, Some("reviewer"), full_session())
1628 .expect("role lookup");
1629 assert_eq!(binding.member_id, "auditor");
1630 assert_eq!(binding.member_role, "reviewer");
1631
1632 let launch = workflow
1633 .route_admitted_task(&binding, "read the diff")
1634 .await
1635 .expect("launch");
1636 assert_eq!(launch.receipt.member_id, "auditor");
1637 assert_eq!(
1638 launch.receipt.member_role, "reviewer",
1639 "the receipt records the semantic role, not the profile id"
1640 );
1641
1642 // The snapshot is addressed by id; the role is the semantic label.
1643 let member = workflow
1644 .snapshot()
1645 .member("auditor")
1646 .expect("snapshot entry");
1647 assert_eq!(member.role, "reviewer");
1648 }
1649
1650 /// A task that names a profile and a role belonging to different members is
1651 /// rejected — the two fields cannot disagree about who ran.
1652 #[test]
1653 fn a_conflicting_task_role_and_profile_is_rejected() {
1654 let workflow = workflow_with(None, GLM_FLEET);
1655
1656 let err = workflow
1657 .bind_member(Some("implementer"), Some("reviewer"), full_session())
1658 .expect_err("conflicting identity");
1659 assert!(err.contains("different members"), "{err}");
1660 assert!(err.contains("implementer"), "{err}");
1661 assert!(err.contains("auditor"), "{err}");
1662
1663 // Agreeing fields are fine: id plus that member's own role.
1664 let binding = workflow
1665 .bind_member(Some("implementer"), Some("builder"), full_session())
1666 .expect("agreeing identity");
1667 assert_eq!(binding.member_id, "implementer");
1668 }
1669
1670 /// Manual reasoning uses no Router at all — not a call whose answer is
1671 /// discarded, but zero calls.
1672 #[tokio::test]
1673 async fn an_explicit_tier_member_never_calls_the_router() {
1674 let router = StaticFleetRouter::new(r#"{"reasoning":"off"}"#);
1675 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
1676
1677 let binding = workflow
1678 .bind_member(Some("auditor"), None, full_session())
1679 .expect("bind");
1680 assert!(!binding.requires_router);
1681
1682 let launch = workflow
1683 .route_admitted_task(&binding, "read the diff")
1684 .await
1685 .expect("explicit tier");
1686
1687 assert_eq!(launch.thinking, "high");
1688 assert_eq!(
1689 launch.reasoning.source(),
1690 EffectiveReasoningSource::MemberExplicit
1691 );
1692 assert_eq!(
1693 router.call_count(),
1694 0,
1695 "an explicit tier must not spend a router call"
1696 );
1697 assert!(launch.receipt.router.is_none());
1698 assert!(launch.receipt.routing_summary.is_none());
1699 assert!(!launch.receipt.cross_provider_inference);
1700 }
1701
1702 /// A task that never reaches admission must never reach the Router. This
1703 /// is the shape of a gate rejection or a capacity block: the caller binds,
1704 /// decides not to proceed, and no provider was contacted.
1705 #[test]
1706 fn a_task_that_is_never_admitted_costs_no_router_call() {
1707 let router = StaticFleetRouter::new(r#"{"reasoning":"max"}"#);
1708 let workflow = workflow_with(Some(router.clone()), GLM_FLEET);
1709
1710 // Unknown member: rejected during binding, before any cost.
1711 assert!(
1712 workflow
1713 .bind_member(None, Some("wizard"), full_session())
1714 .is_err()
1715 );
1716 // Conflicting identity: likewise.
1717 assert!(
1718 workflow
1719 .bind_member(Some("implementer"), Some("reviewer"), full_session())
1720 .is_err()
1721 );
1722 // A valid binding that the caller then abandons (gate reject / no slot).
1723 let _binding = workflow
1724 .bind_member(None, Some("builder"), full_session())
1725 .expect("valid binding");
1726
1727 assert_eq!(
1728 router.call_count(),
1729 0,
1730 "no router call may happen before a task is admitted"
1731 );
1732 }
1733
1734 #[tokio::test]
1735 async fn a_router_that_tries_to_move_the_route_fails_the_launch() {
1736 let workflow = workflow_with(
1737 Some(StaticFleetRouter::new(
1738 r#"{"reasoning":"max","model":"glm-4"}"#,
1739 )),
1740 GLM_FLEET,
1741 );
1742 let binding = workflow
1743 .bind_member(None, Some("builder"), full_session())
1744 .expect("bind");
1745
1746 let err = workflow
1747 .route_admitted_task(&binding, "anything")
1748 .await
1749 .expect_err("a route mutation must fail the launch");
1750 assert!(err.contains("frozen"), "{err}");
1751 }
1752
1753 #[tokio::test]
1754 async fn a_duplicate_reasoning_key_fails_the_launch() {
1755 let workflow = workflow_with(
1756 Some(StaticFleetRouter::new(
1757 r#"{"reasoning":"off","reasoning":"max"}"#,
1758 )),
1759 GLM_FLEET,
1760 );
1761 let binding = workflow
1762 .bind_member(None, Some("builder"), full_session())
1763 .expect("bind");
1764
1765 let err = workflow
1766 .route_admitted_task(&binding, "anything")
1767 .await
1768 .expect_err("duplicate key");
1769 assert!(err.contains("more than once"), "{err}");
1770 }
1771
1772 #[test]
1773 fn a_missing_router_fails_before_any_worker_is_dispatched() {
1774 let router_less = GLM_FLEET.replace("reasoning_router = \"luna-low\"\n", "");
1775 let document = FleetDocument::parse(&router_less).expect("parse");
1776 let workflow = ExactFleetWorkflow::for_tests(&document, id(), None);
1777
1778 let err = workflow
1779 .reject_unusable_auto_members()
1780 .expect_err("auto without a router must not start");
1781 assert!(err.contains("implementer"), "{err}");
1782 assert!(err.contains("reasoning router"), "{err}");
1783 assert!(
1784 err.contains("never fall back"),
1785 "the error must rule out legacy fallback: {err}"
1786 );
1787 }
1788
1789 #[test]
1790 fn a_fleet_with_no_auto_member_starts_without_a_router() {
1791 let text = r#"
1792 name = "pinned"
1793 schema = "exact"
1794
1795 [[members]]
1796 id = "auditor"
1797 provider = "zai"
1798 model = "glm-5"
1799 reasoning = "high"
1800 permissions = "read_only"
1801 "#;
1802 let document = FleetDocument::parse(text).expect("parse");
1803 let workflow = ExactFleetWorkflow::for_tests(
1804 &document,
1805 QualifiedFleetId {
1806 name: "pinned".to_string(),
1807 origin: "workspace".to_string(),
1808 },
1809 None,
1810 );
1811 workflow
1812 .reject_unusable_auto_members()
1813 .expect("no auto member means no router requirement");
1814 assert_eq!(workflow.snapshot().members().len(), 1);
1815 }
1816
1817 /// A Router whose credentials are locally absent fails the Workflow before
1818 /// any worker is dispatched — decided from local config, never from a live
1819 /// probe of the provider.
1820 #[test]
1821 fn a_locally_unusable_router_fails_before_any_worker_is_dispatched() {
1822 let document = FleetDocument::parse(GLM_FLEET).expect("parse");
1823 let err = ExactFleetWorkflow::for_tests_with_unavailable_router(
1824 &document,
1825 id(),
1826 "no credential configured for `openai`",
1827 )
1828 .expect_err("an unusable router must not start an auto Fleet");
1829
1830 assert!(err.contains("cannot start"), "{err}");
1831 assert!(err.contains("implementer"), "{err}");
1832 assert!(err.contains("no credential configured"), "{err}");
1833 assert!(err.contains("never fall back"), "{err}");
1834 }
1835
1836 #[test]
1837 fn the_frozen_route_pins_each_members_exact_provider_and_model() {
1838 let workflow = workflow_with(None, GLM_FLEET);
1839 let route = workflow
1840 .preflight
1841 .worker("implementer")
1842 .expect("preflighted worker");
1843
1844 assert_eq!(route.provider_id, "zai");
1845 assert_eq!(route.wire_model, "glm-5");
1846 let member = workflow
1847 .snapshot()
1848 .member("implementer")
1849 .expect("snapshot entry");
1850 assert!(
1851 member.requested_reasoning.is_auto(),
1852 "reasoning is decided per task, not baked into the frozen route"
1853 );
1854 }
1855
1856 /// Binding carries route and Runtime role, but no Fleet-owned authority.
1857 #[test]
1858 fn bound_members_use_runtime_roles_and_neutral_compatibility_fields() {
1859 let workflow = workflow_with(None, GLM_FLEET);
1860 for (id, expected_posture, expected_write) in [
1861 ("auditor", "reviewer", "read_only"),
1862 ("implementer", "implement", "workspace_write"),
1863 ] {
1864 let binding = workflow
1865 .bind_member(Some(id), None, full_session())
1866 .expect("bind");
1867 assert_eq!(
1868 binding.authority.posture_role, expected_posture,
1869 "{id} must resolve through Runtime's closed role policy"
1870 );
1871 assert_eq!(
1872 binding.authority.write_authority, expected_write,
1873 "{id} authority comes from the role posture, never a Fleet permissions block"
1874 );
1875 }
1876 }
1877
1878 /// #5575: a free-form member role keeps its identity and **fails closed**.
1879 ///
1880 /// This test previously asserted the opposite — `posture_role == "custom"`
1881 /// and `write_authority == "workspace_write"` — which is exactly the defect:
1882 /// `audit-lead` is a name nobody declared, and the exact driver answered it
1883 /// with the widest posture there is while the durable driver answered the
1884 /// same class of name with `general`. Identity is still preserved verbatim
1885 /// (`member_role`), but an undeclared label now buys the narrowest useful
1886 /// posture, not write authority.
1887 #[test]
1888 fn a_free_form_member_role_fails_closed_without_losing_identity() {
1889 const AUDIT_FLEET: &str = r#"
1890 name = "audit"
1891 schema = "exact"
1892
1893 [[members]]
1894 id = "auditor-one"
1895 role = "audit-lead"
1896 provider = "zai"
1897 model = "glm-5"
1898 permissions = "read_only"
1899 "#;
1900 let workflow = workflow_with(None, AUDIT_FLEET);
1901 let binding = workflow
1902 .bind_member(Some("auditor-one"), None, full_session())
1903 .expect("bind");
1904
1905 assert_eq!(binding.member_role, "audit-lead");
1906 assert_eq!(binding.authority.posture_role, "explore");
1907 assert_eq!(
1908 binding.authority.write_authority, "read_only",
1909 "an undeclared role name must never grant write authority; an \
1910 operator who wants the parent's posture spells the role `custom`"
1911 );
1912
1913 // The escape hatch is a declared role, not a typo.
1914 assert_eq!(
1915 ChildAuthority::from_runtime_role("custom", full_session()).write_authority,
1916 "workspace_write"
1917 );
1918 }
1919
1920 // ── Permission ceilings, as the child actually experiences them ─────────
1921
1922 /// `tools = false` means zero model tools — an empty allowlist, which the
1923 /// child registry treats as "nothing is visible and nothing is callable".
1924 #[test]
1925 fn tools_false_yields_an_empty_tool_surface() {
1926 let authority = ChildAuthority::clamp(PermissionCeiling::ROUTER, full_session());
1927
1928 assert!(!authority.ceiling.tools);
1929 assert_eq!(
1930 authority.allowed_tools.as_deref(),
1931 Some(&[] as &[String]),
1932 "tools = false must be an empty allowlist, not an absent one"
1933 );
1934 assert_eq!(authority.write_authority, "read_only");
1935 assert_eq!(authority.max_depth, 0);
1936 }
1937
1938 /// `network_tool = false` removes every model-visible network, browser,
1939 /// and remote-MCP surface except the `Web` family's two read-only actions
1940 /// — even when `tools = true`. The family *name* must survive the deny
1941 /// list so the child registry's action seam can grant exactly
1942 /// `search`/`fetch`; every other browsing spelling is denied.
1943 #[test]
1944 fn network_disabled_denies_every_network_surface_even_with_tools_enabled() {
1945 let member = PermissionCeiling::preset("read_write").expect("preset");
1946 assert!(member.tools);
1947 assert!(!member.network_tool);
1948
1949 let authority = ChildAuthority::clamp(member, full_session());
1950
1951 assert!(
1952 authority.allowed_tools.is_none(),
1953 "a tool-using member keeps full inheritance, narrowed by the deny list"
1954 );
1955 for expected in [
1956 "web.run",
1957 "web_run",
1958 "web_search",
1959 "fetch_url",
1960 "wait_for_dev_server",
1961 "github",
1962 "mcp*",
1963 ] {
1964 assert!(
1965 authority
1966 .disallowed_tools
1967 .iter()
1968 .any(|name| name == expected),
1969 "{expected} must be denied: {:?}",
1970 authority.disallowed_tools
1971 );
1972 }
1973 // The canonical family name is what the read-only web surface
1974 // dispatches under; only its reaching spellings are denied.
1975 assert!(
1976 !authority.disallowed_tools.iter().any(|name| name == "Web"),
1977 "the Web family name must survive so search/fetch stay reachable: {:?}",
1978 authority.disallowed_tools
1979 );
1980
1981 // A member that IS allowed a network tool gets no such deny list.
1982 let networked = ChildAuthority::clamp(
1983 PermissionCeiling::preset("full").expect("preset"),
1984 full_session(),
1985 );
1986 assert!(networked.ceiling.network_tool);
1987 assert!(networked.disallowed_tools.is_empty());
1988 }
1989
1990 /// The browsing capability is registered under several names, and `web.run`
1991 /// is the one a deny list stopping at the `Web` family name leaves behind.
1992 /// A network-denied member that can still call `web.run` is not
1993 /// network-denied, so every spelling *except* the family name itself —
1994 /// which the action seam bounds to `search`/`fetch` — stays on the list.
1995 #[test]
1996 fn network_disabled_denies_the_canonical_web_run_surface_and_its_aliases() {
1997 let authority = ChildAuthority::clamp(
1998 PermissionCeiling::preset("read_write").expect("preset"),
1999 full_session(),
2000 );
2001
2002 let denied = |name: &str| {
2003 let lowered = name.to_ascii_lowercase();
2004 authority.disallowed_tools.iter().any(|rule| {
2005 let rule = rule.to_ascii_lowercase();
2006 rule.strip_suffix('*')
2007 .map_or(rule == lowered, |prefix| lowered.starts_with(prefix))
2008 })
2009 };
2010
2011 for name in [
2012 "web.run",
2013 "web_run",
2014 "web_search",
2015 "web.fetch",
2016 "web_fetch",
2017 "fetch_url",
2018 "wait_for_dev_server",
2019 "browse",
2020 "browser",
2021 ] {
2022 assert!(
2023 denied(name),
2024 "{name} must be denied: {:?}",
2025 authority.disallowed_tools
2026 );
2027 }
2028 // The family name itself is what the read-only search/fetch surface
2029 // dispatches under; the action seam and the URL-input guard bound it.
2030 assert!(
2031 !denied("Web"),
2032 "the Web family name must survive a network denial: {:?}",
2033 authority.disallowed_tools
2034 );
2035 // The globs must not reach past the browsing family.
2036 for name in ["read_file", "run_tests", "Git", "grep_files"] {
2037 assert!(!denied(name), "{name} is not a network surface");
2038 }
2039 }
2040
2041 /// `rlm` reaches the network without ever naming a network tool: `open`
2042 /// fetches a `url` by calling `FetchUrlTool` in-process, and `eval` runs
2043 /// Python that owns a socket API. Denying `fetch_url` sees neither call, so
2044 /// both actions carry their own deny-list entries.
2045 #[test]
2046 fn network_disabled_denies_the_in_process_rlm_reach() {
2047 let authority = ChildAuthority::clamp(
2048 PermissionCeiling::preset("read_write").expect("preset"),
2049 full_session(),
2050 );
2051
2052 let denied = |name: &str| {
2053 let lowered = name.to_ascii_lowercase();
2054 authority.disallowed_tools.iter().any(|rule| {
2055 let rule = rule.to_ascii_lowercase();
2056 rule.strip_suffix('*')
2057 .map_or(rule == lowered, |prefix| lowered.starts_with(prefix))
2058 })
2059 };
2060
2061 for reaching in ["rlm_open", "rlm_eval"] {
2062 assert!(
2063 denied(reaching),
2064 "{reaching} reaches the network in-process and must be denied: {:?}",
2065 authority.disallowed_tools
2066 );
2067 }
2068 // The fail-closed narrowing is deliberate but *bounded*: the bounded
2069 // local metadata actions survive, and so does the family itself, so the
2070 // per-action seam has something left to permit.
2071 for kept in ["rlm", "rlm_session_objects", "rlm_configure", "rlm_close"] {
2072 assert!(
2073 !denied(kept),
2074 "{kept} is bounded local metadata and must survive a network denial"
2075 );
2076 }
2077 }
2078
2079 /// The deny-list sentinel has to actually be on the deny list, or every
2080 /// posture check derived from it silently reads "network allowed".
2081 #[test]
2082 fn the_network_denial_sentinel_is_installed_by_a_network_denial() {
2083 assert!(
2084 NETWORK_TOOL_DENYLIST.contains(&NETWORK_DENIAL_SENTINEL),
2085 "{NETWORK_DENIAL_SENTINEL} must be an explicit entry, not a glob match"
2086 );
2087 let authority = ChildAuthority::clamp(
2088 PermissionCeiling::preset("read_write").expect("preset"),
2089 full_session(),
2090 );
2091 assert!(
2092 authority
2093 .disallowed_tools
2094 .iter()
2095 .any(|rule| rule == NETWORK_DENIAL_SENTINEL),
2096 "a network denial must install the sentinel verbatim: {:?}",
2097 authority.disallowed_tools
2098 );
2099 // …and a network-*capable* member must not, or the sentinel would read
2100 // as denied for everyone.
2101 let networked = ChildAuthority::clamp(
2102 PermissionCeiling::preset("full").expect("preset"),
2103 full_session(),
2104 );
2105 assert!(
2106 !networked
2107 .disallowed_tools
2108 .iter()
2109 .any(|rule| rule == NETWORK_DENIAL_SENTINEL)
2110 );
2111 }
2112
2113 /// Every network-denied preset — read_only/read-only inspection included — leaves the
2114 /// `Web` family name reachable and seals each of its reaching spellings.
2115 /// This is the deny-list half of the read-only web-search contract; the
2116 /// registry-side half (exactly `search`/`fetch`, with URL-addressed calls
2117 /// refused) is asserted in `subagent/tests.rs`.
2118 #[test]
2119 fn every_network_denial_leaves_web_search_reachable_by_family_name() {
2120 for preset in ["analyst", "read_only", "verifier", "read_write"] {
2121 let authority = ChildAuthority::clamp(
2122 PermissionCeiling::preset(preset).expect("preset"),
2123 full_session(),
2124 );
2125 assert!(
2126 !authority.ceiling.network_tool,
2127 "{preset} is network-denied"
2128 );
2129 assert!(
2130 !authority.disallowed_tools.iter().any(|rule| rule == "Web"),
2131 "{preset} must keep the Web family name: {:?}",
2132 authority.disallowed_tools
2133 );
2134 for sealed in [
2135 "web_*",
2136 "web.*",
2137 "web.run",
2138 "web_run",
2139 "web_search",
2140 "web.fetch",
2141 "web_fetch",
2142 "fetch_url",
2143 "wait_for_dev_server",
2144 "github",
2145 "mcp*",
2146 ] {
2147 assert!(
2148 authority.disallowed_tools.iter().any(|rule| rule == sealed),
2149 "{preset} must deny {sealed}: {:?}",
2150 authority.disallowed_tools
2151 );
2152 }
2153 }
2154 }
2155
2156 /// A member saved as `write = false` must not receive a mutating surface —
2157 /// including the raw shell a `verifier`-shaped ceiling keeps for running
2158 /// checks. `rm -rf` mutates a workspace exactly as well as `write_file`,
2159 /// and a receipt that says `write=false` while the child holds `exec_shell`
2160 /// is not true.
2161 #[test]
2162 fn a_read_only_member_gets_a_truthful_non_mutating_tool_contract() {
2163 let verifier = PermissionCeiling::preset("verifier").expect("preset");
2164 assert!(!verifier.write);
2165 assert_eq!(verifier.shell, ShellCeiling::Full);
2166
2167 let authority = ChildAuthority::clamp(verifier, full_session());
2168 assert_eq!(authority.write_authority, "read_only");
2169
2170 let denied = |name: &str| {
2171 authority.disallowed_tools.iter().any(|rule| {
2172 rule == name || rule.strip_suffix('*').is_some_and(|p| name.starts_with(p))
2173 })
2174 };
2175
2176 // `rlm_eval` belongs on this list for the same reason `exec_shell` does:
2177 // the Python it runs writes files. A tool is a mutation primitive
2178 // because of what it can do, not because of what it is called.
2179 for mutating in [
2180 "write_file",
2181 "edit_file",
2182 "apply_patch",
2183 "fim_edit",
2184 "rlm_eval",
2185 ] {
2186 assert!(
2187 denied(mutating),
2188 "{mutating} must be denied for a read-only member: {:?}",
2189 authority.disallowed_tools
2190 );
2191 }
2192 for raw_shell in [
2193 "Bash",
2194 "exec_shell",
2195 "exec_shell_interact",
2196 "task_shell_start",
2197 "terminal/run",
2198 ] {
2199 assert!(
2200 denied(raw_shell),
2201 "{raw_shell} is a general mutation primitive: {:?}",
2202 authority.disallowed_tools
2203 );
2204 }
2205 // What the member is *for* survives: the bounded verification surface.
2206 // (`rlm_open` is absent from this list only because the `verifier`
2207 // preset is also network-denied; the write contract alone keeps it —
2208 // see `a_write_denial_alone_keeps_local_rlm_loading`.)
2209 for kept in [
2210 "Run",
2211 "run_tests",
2212 "run_verifiers",
2213 "read_file",
2214 "grep_files",
2215 "rlm",
2216 ] {
2217 assert!(!denied(kept), "{kept} must stay available to a verifier");
2218 }
2219
2220 // A write-capable member is untouched by this contract.
2221 let builder = ChildAuthority::clamp(
2222 PermissionCeiling::preset("read_write").expect("preset"),
2223 full_session(),
2224 );
2225 assert!(builder.ceiling.write);
2226 for kept in ["write_file", "apply_patch", "exec_shell"] {
2227 assert!(
2228 !builder.disallowed_tools.iter().any(|rule| rule == kept),
2229 "{kept} must stay available to a write-capable member"
2230 );
2231 }
2232 }
2233
2234 /// The two denials are separate contracts and must not bleed into each
2235 /// other. A member that may not *write* can still load a large local file
2236 /// into an RLM kernel and read it — that is analysis, not mutation. Only
2237 /// `eval` goes, because only `eval` runs code.
2238 #[test]
2239 fn a_write_denial_alone_keeps_local_rlm_loading() {
2240 let member = PermissionCeiling {
2241 write: false,
2242 network_tool: true,
2243 shell: ShellCeiling::ReadOnly,
2244 delegation_depth: 0,
2245 tools: true,
2246 };
2247 let authority = ChildAuthority::clamp(member, full_session());
2248 assert!(!authority.ceiling.write);
2249 assert!(authority.ceiling.network_tool);
2250
2251 let denied = |name: &str| authority.disallowed_tools.iter().any(|rule| rule == name);
2252
2253 assert!(denied("rlm_eval"), "eval runs code, so it mutates");
2254 for kept in ["rlm", "rlm_open", "rlm_session_objects", "rlm_close"] {
2255 assert!(
2256 !denied(kept),
2257 "{kept} loads and inspects; it does not mutate: {:?}",
2258 authority.disallowed_tools
2259 );
2260 }
2261 }
2262
2263 /// The parent posture always wins. A saved `full` member inside a
2264 /// read-only, no-network, no-shell session runs at the session's ceiling.
2265 #[test]
2266 fn the_parent_ceiling_wins_over_a_wider_saved_member() {
2267 let session = PermissionCeiling {
2268 write: false,
2269 network_tool: false,
2270 shell: ShellCeiling::ReadOnly,
2271 delegation_depth: 0,
2272 tools: true,
2273 };
2274 let member = PermissionCeiling::preset("full").expect("preset");
2275 assert!(member.write && member.network_tool);
2276
2277 let authority = ChildAuthority::clamp(member, session);
2278
2279 assert!(!authority.ceiling.write, "a Fleet may not grant write");
2280 assert!(
2281 !authority.ceiling.network_tool,
2282 "a Fleet may not grant a network tool"
2283 );
2284 assert_eq!(authority.ceiling.shell, ShellCeiling::ReadOnly);
2285 assert_eq!(authority.ceiling.delegation_depth, 0);
2286 assert_eq!(authority.write_authority, "read_only");
2287 assert_eq!(authority.max_depth, 0);
2288 assert_eq!(authority.posture_role, "explore");
2289 assert!(!authority.disallowed_tools.is_empty());
2290 }
2291
2292 /// A read-only session cannot be widened by a session that *is* permissive
2293 /// either — clamping is symmetric, and takes the narrower side each way.
2294 #[test]
2295 fn clamping_takes_the_narrower_side_of_every_field() {
2296 let narrow_member = PermissionCeiling {
2297 write: false,
2298 network_tool: false,
2299 shell: ShellCeiling::None,
2300 delegation_depth: 0,
2301 tools: true,
2302 };
2303 let authority = ChildAuthority::clamp(narrow_member, full_session());
2304
2305 assert!(!authority.ceiling.write);
2306 assert_eq!(authority.ceiling.shell, ShellCeiling::None);
2307 assert_eq!(authority.ceiling.delegation_depth, 0);
2308 }
2309
2310 // ── Preflight ──────────────────────────────────────────────────────────
2311
2312 /// Z.AI GLM routes express only thinking enabled/disabled, so `high` and
2313 /// `max` must not be reported as two distinct provider-effective tiers.
2314 #[test]
2315 fn glm_routes_report_an_enabled_disabled_provider_control() {
2316 let capability = reasoning_capability_for_route(
2317 ApiProvider::Zai,
2318 crate::config::DEFAULT_ZAI_BASE_URL,
2319 crate::config::ZAI_GLM_5_2_MODEL,
2320 );
2321
2322 assert_eq!(
2323 capability.control,
2324 ProviderReasoningControl::EnabledDisabled,
2325 "Z.AI's request shaping emits only thinking enabled/disabled"
2326 );
2327 assert!(!capability.supports_native_adaptive());
2328 assert_eq!(
2329 capability.provider_effective(ReasoningTier::High),
2330 ProviderEffectiveReasoning::Enabled
2331 );
2332 assert_eq!(
2333 capability.provider_effective(ReasoningTier::Off),
2334 ProviderEffectiveReasoning::Disabled
2335 );
2336 }
2337
2338 /// DeepSeek varies `reasoning_effort` per tier, so its tiers are real.
2339 #[test]
2340 fn a_route_that_varies_its_wire_value_reports_distinct_tiers() {
2341 let capability = reasoning_capability_for_route(
2342 ApiProvider::Deepseek,
2343 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
2344 "deepseek-v4-pro",
2345 );
2346 assert_eq!(capability.control, ProviderReasoningControl::Tiers);
2347 }
2348
2349 /// First-party DeepSeek routes document `reasoning_effort` low/high/max
2350 /// on the wire (no medium), so `low` is a real tier there. The capability
2351 /// must report the tier the route *sends*, not the tier the selector
2352 /// named: low reaches the wire as low, medium rounds up to high because
2353 /// the dialect has no such value (#52).
2354 #[test]
2355 fn a_deepseek_route_reports_low_as_low_and_medium_as_high() {
2356 let capability = reasoning_capability_for_route(
2357 ApiProvider::Deepseek,
2358 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
2359 "deepseek-v4-pro",
2360 );
2361
2362 // Exactly what the request shaping does, read back off the capability.
2363 for (requested, expected) in [
2364 (ReasoningTier::Low, ReasoningTier::Low),
2365 (ReasoningTier::Medium, ReasoningTier::High),
2366 (ReasoningTier::High, ReasoningTier::High),
2367 (ReasoningTier::Max, ReasoningTier::Max),
2368 (ReasoningTier::Off, ReasoningTier::Off),
2369 ] {
2370 assert_eq!(
2371 capability.wire_tier(requested),
2372 expected,
2373 "requested {requested:?} must be reported as what the wire carries"
2374 );
2375 let (effective, normalized) = capability.normalize(requested);
2376 assert_eq!(effective, expected);
2377 assert_eq!(normalized, requested != expected);
2378 }
2379
2380 // And the resolver carries that all the way onto the receipt.
2381 let resolved = codewhale_workflow::resolve_exact_member_reasoning(
2382 "implementer",
2383 &codewhale_workflow::FrozenRoute {
2384 provider: "deepseek".to_string(),
2385 model: "deepseek-v4-pro".to_string(),
2386 },
2387 RequestedReasoning::Low,
2388 &capability,
2389 &RouterAvailability::Absent,
2390 None,
2391 None,
2392 )
2393 .expect("resolve");
2394 assert_eq!(resolved.requested(), RequestedReasoning::Low);
2395 assert_eq!(
2396 resolved.effective(),
2397 codewhale_workflow::EffectiveReasoning::Tier(ReasoningTier::Low)
2398 );
2399 assert!(!resolved.capability_normalized());
2400 }
2401
2402 /// Routes whose dialect has no low tier still collapse low onto high, and
2403 /// the capability must say so instead of reporting a `low` the wire never
2404 /// carried. CodeWhale's normalizer keeps the historic low/medium → high
2405 /// coercion for these DeepSeek-compatible hosted routes because their own
2406 /// wire contracts are not verified.
2407 #[test]
2408 fn a_route_that_collapses_low_onto_high_says_so_instead_of_reporting_low() {
2409 let capability = reasoning_capability_for_route(
2410 ApiProvider::Siliconflow,
2411 crate::config::DEFAULT_SILICONFLOW_BASE_URL,
2412 "deepseek-ai/DeepSeek-V4-Pro",
2413 );
2414
2415 for (requested, expected) in [
2416 (ReasoningTier::Low, ReasoningTier::High),
2417 (ReasoningTier::Medium, ReasoningTier::High),
2418 (ReasoningTier::High, ReasoningTier::High),
2419 (ReasoningTier::Max, ReasoningTier::Max),
2420 (ReasoningTier::Off, ReasoningTier::Off),
2421 ] {
2422 assert_eq!(
2423 capability.wire_tier(requested),
2424 expected,
2425 "requested {requested:?} must be reported as what the wire carries"
2426 );
2427 let (effective, normalized) = capability.normalize(requested);
2428 assert_eq!(effective, expected);
2429 assert_eq!(normalized, requested != expected);
2430 }
2431 }
2432
2433 /// Preflight resolves the provider, canonicalizes the model, identifies the
2434 /// endpoint, and decides credential readiness — all from local config.
2435 #[test]
2436 fn preflight_freezes_provider_model_endpoint_and_local_readiness() {
2437 let _env_lock = crate::test_support::lock_test_env();
2438 let _key = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
2439 let config = Config {
2440 provider: Some("zai".to_string()),
2441 ..Default::default()
2442 };
2443
2444 let route = preflight_route(
2445 "implementer",
2446 "zai",
2447 crate::config::ZAI_GLM_5_2_MODEL,
2448 &config,
2449 )
2450 .expect("preflight");
2451
2452 assert_eq!(route.member_id, "implementer");
2453 assert_eq!(route.provider_kind, "zai");
2454 assert_eq!(route.wire_model, crate::config::ZAI_GLM_5_2_MODEL);
2455 assert!(!route.endpoint.host.is_empty());
2456 assert!(!route.endpoint.host.contains('/'));
2457 assert_eq!(route.credential, CredentialReadiness::Configured);
2458 route.require_ready().expect("ready");
2459
2460 // The receipt and the child spawn read the same canonical wire model.
2461 assert_eq!(route.frozen().model, route.wire_model);
2462 }
2463
2464 /// A keyless local provider is valid, and is decided without a probe.
2465 #[test]
2466 fn a_keyless_local_provider_preflights_as_ready() {
2467 let _env_lock = crate::test_support::lock_test_env();
2468 let config = Config {
2469 provider: Some("ollama".to_string()),
2470 ..Default::default()
2471 };
2472
2473 let Ok(route) = preflight_route("worker", "ollama", "qwen3", &config) else {
2474 // A model id this build does not know is a different failure than
2475 // the one under test; skip rather than assert on the catalog.
2476 return;
2477 };
2478 assert_eq!(route.credential, CredentialReadiness::KeylessLocal);
2479 assert!(route.credential.is_ready());
2480 route.require_ready().expect("keyless local is valid");
2481 assert!(route.endpoint.local, "a local runtime is marked local");
2482 }
2483
2484 #[test]
2485 fn ollama_cloud_and_custom_remote_preflight_require_route_scoped_credentials() {
2486 let _env_lock = crate::test_support::lock_test_env();
2487 let temp = tempfile::tempdir().expect("isolated credential home");
2488 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
2489 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2490 let _ollama_cloud_key = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
2491 let _ollama_key = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
2492 let _cli_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
2493 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
2494 codewhale_secrets::Secrets::auto_detect()
2495 .set("ollama", "legacy-cloud-key")
2496 .expect("seed released Ollama Cloud slot");
2497
2498 let cloud = Config {
2499 provider: Some("deepseek".to_string()),
2500 providers: Some(crate::config::ProvidersConfig {
2501 ollama: crate::config::ProviderConfig {
2502 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
2503 ..Default::default()
2504 },
2505 ..Default::default()
2506 }),
2507 ..Default::default()
2508 };
2509 let cloud_route = preflight_route(
2510 "cloud-worker",
2511 "ollama",
2512 crate::config::DEFAULT_OLLAMA_MODEL,
2513 &cloud,
2514 )
2515 .expect("official Cloud route");
2516 assert_eq!(cloud_route.provider_id, "ollama-cloud");
2517 assert_eq!(cloud_route.provider_config_id.as_deref(), Some("ollama"));
2518 assert_eq!(cloud_route.provider_kind, "ollama-cloud");
2519 assert_eq!(cloud_route.credential, CredentialReadiness::Configured);
2520 assert!(!cloud_route.endpoint.local);
2521 cloud_route.require_ready().expect("Cloud env key is ready");
2522
2523 let custom_remote = Config {
2524 provider: Some("ollama".to_string()),
2525 providers: Some(crate::config::ProvidersConfig {
2526 ollama: crate::config::ProviderConfig {
2527 base_url: Some("https://ollama-gateway.example.test/v1".to_string()),
2528 ..Default::default()
2529 },
2530 ..Default::default()
2531 }),
2532 ..Default::default()
2533 };
2534 let custom_route = preflight_route(
2535 "custom-worker",
2536 "ollama",
2537 crate::config::DEFAULT_OLLAMA_MODEL,
2538 &custom_remote,
2539 )
2540 .expect("custom route still resolves structurally");
2541 assert!(matches!(
2542 custom_route.credential,
2543 CredentialReadiness::Missing { .. }
2544 ));
2545 assert!(!custom_route.endpoint.local);
2546 assert!(custom_route.require_ready().is_err());
2547 }
2548
2549 #[tokio::test]
2550 async fn legacy_ollama_cloud_fleet_start_builds_clients_from_the_frozen_source_route() {
2551 let _env_lock = crate::test_support::lock_test_env();
2552 let temp = tempfile::tempdir().expect("isolated credential home");
2553 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
2554 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
2555 let _cloud_env = crate::test_support::EnvVarGuard::remove("OLLAMA_CLOUD_API_KEY");
2556 let _official_env = crate::test_support::EnvVarGuard::remove("OLLAMA_API_KEY");
2557 codewhale_secrets::Secrets::auto_detect()
2558 .set("ollama", "legacy-cloud-fleet-key")
2559 .expect("seed released Ollama Cloud slot");
2560
2561 let config = Config {
2562 provider: Some("deepseek".to_string()),
2563 providers: Some(crate::config::ProvidersConfig {
2564 ollama: crate::config::ProviderConfig {
2565 base_url: Some(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL.to_string()),
2566 model: Some(crate::config::DEFAULT_OLLAMA_CLOUD_MODEL.to_string()),
2567 ..Default::default()
2568 },
2569 ..Default::default()
2570 }),
2571 ..Default::default()
2572 };
2573 let document = FleetDocument::parse(&format!(
2574 r#"
2575 name = "glm-pair"
2576 schema = "exact"
2577
2578 [[members]]
2579 id = "cloud-worker"
2580 role = "builder"
2581 provider = "ollama"
2582 model = "{}"
2583 reasoning = "medium"
2584 permissions = "read_only"
2585 "#,
2586 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL
2587 ))
2588 .expect("legacy Cloud Fleet parses");
2589
2590 // `capture` is the real Workflow-start path: it preflights readiness,
2591 // constructs every worker client, and freezes the snapshot.
2592 let workflow = ExactFleetWorkflow::capture(
2593 &document,
2594 id(),
2595 "2026-08-14T00:00:00Z",
2596 Some(&config),
2597 &[],
2598 )
2599 .expect("legacy Cloud Fleet starts");
2600 let route = workflow
2601 .preflight
2602 .worker("cloud-worker")
2603 .expect("preflighted worker");
2604 assert_eq!(route.provider_id, "ollama-cloud");
2605 assert_eq!(route.provider_config_id.as_deref(), Some("ollama"));
2606
2607 let binding = workflow
2608 .bind_member(Some("cloud-worker"), None, full_session())
2609 .expect("worker binds");
2610 let launch = workflow
2611 .route_admitted_task(&binding, "verify the frozen Cloud route")
2612 .await
2613 .expect("manual-tier launch needs no provider call");
2614 assert_eq!(launch.provider, "ollama-cloud");
2615 assert_eq!(launch.receipt.provider, "ollama-cloud");
2616
2617 let router_profile = ReasoningRouterProfile::parse(&format!(
2618 r#"
2619 name = "legacy-cloud-router"
2620 schema = "reasoning_router"
2621 provider = "ollama"
2622 model = "{}"
2623 call_reasoning = "low"
2624 "#,
2625 crate::config::DEFAULT_OLLAMA_CLOUD_MODEL
2626 ))
2627 .expect("legacy Cloud router profile parses");
2628 let captured =
2629 CapturedReasoningRouter::from_profile(&router_profile, "workspace".to_string());
2630 let live = LiveFleetRouter::bind(&captured, &config)
2631 .expect("legacy Cloud Router binds its source table and secret");
2632 assert_eq!(live.route.provider_id, "ollama-cloud");
2633 assert_eq!(live.route.provider_config_id.as_deref(), Some("ollama"));
2634 assert_eq!(live.client.api_provider(), ApiProvider::OllamaCloud);
2635 assert_eq!(
2636 live.client.base_url(),
2637 codewhale_config::provider::OLLAMA_CLOUD_BASE_URL
2638 );
2639 }
2640
2641 /// A tier label is a selector concept; what a request may carry is a
2642 /// provider concept. The value placed on a call must come from the route
2643 /// normalizer the client actually uses, or a Codex-routed Router is called
2644 /// at the provider default while its receipt claims a tier.
2645 #[test]
2646 fn a_call_reasoning_value_is_shaped_by_the_configured_route_not_a_tier_label() {
2647 // A tiered non-Codex route spells the tiers the ordinary way, after
2648 // the same route normalization the client performs (first-party
2649 // DeepSeek keeps a real `low`; medium still rounds up to high).
2650 for (tier, expected) in [
2651 (ReasoningTier::Off, "off"),
2652 (ReasoningTier::High, "high"),
2653 (ReasoningTier::Max, "max"),
2654 ] {
2655 assert_eq!(
2656 route_reasoning_setting(
2657 ApiProvider::Deepseek,
2658 crate::config::DEFAULT_DEEPSEEK_BASE_URL,
2659 "deepseek-v4-pro",
2660 tier,
2661 ),
2662 expected,
2663 "{tier:?} on a deepseek route"
2664 );
2665 }
2666
2667 // Codex is the case a bare tier label gets wrong in both directions:
2668 // it has no `off`, and its ladder now spells three separate top rungs
2669 // (`xhigh`, `max`, `ultra`) that the roster publishes per model.
2670 let codex = |tier| {
2671 route_reasoning_setting(
2672 ApiProvider::OpenaiCodex,
2673 "https://chatgpt.com/backend-api/codex",
2674 "gpt-5.6-codex",
2675 tier,
2676 )
2677 };
2678 assert_eq!(codex(ReasoningTier::Max), "max");
2679 assert_eq!(codex(ReasoningTier::Low), "low");
2680 assert_ne!(
2681 codex(ReasoningTier::Off),
2682 "off",
2683 "an always-thinking route cannot be asked for `off`; sending the label \
2684 would take the provider default while the receipt claimed a tier"
2685 );
2686 }
2687
2688 /// An unresolvable provider fails preflight rather than reaching a launch.
2689 #[test]
2690 fn an_unresolvable_provider_fails_preflight() {
2691 let config = Config::default();
2692 let err = preflight_route("implementer", "not-a-provider", "whatever", &config)
2693 .expect_err("unresolvable provider");
2694 assert!(matches!(err, PreflightError::ProviderUnresolved { .. }));
2695 }
2696
2697 // ── Receipts ───────────────────────────────────────────────────────────
2698
2699 /// The receipt is the durable artifact. It must carry every side of the
2700 /// decision — including which service chose the tier and what that call was
2701 /// configured to cost — and must store no task text, path, or key.
2702 #[tokio::test]
2703 async fn a_launch_receipt_names_the_service_route_and_call_cost_without_content() {
2704 let workflow = workflow_with(
2705 Some(StaticFleetRouter::new(r#"{"reasoning":"max"}"#)),
2706 GLM_FLEET,
2707 );
2708 let binding = workflow
2709 .bind_member(None, Some("builder"), full_session())
2710 .expect("bind");
2711
2712 let launch = workflow
2713 .route_admitted_task(&binding, "refactor /Users/hunter/app with ZAI_API_KEY=zzz")
2714 .await
2715 .expect("launch");
2716 let receipt = &launch.receipt;
2717
2718 assert_eq!(receipt.fleet, "workspace/glm-pair");
2719 assert_eq!(receipt.schema_kind, "exact");
2720 assert_eq!(receipt.member_id, "implementer");
2721 assert_eq!(receipt.member_role, "implement");
2722 assert_eq!(receipt.provider, "zai");
2723 assert_eq!(receipt.model, "glm-5");
2724 assert_eq!(receipt.requested_reasoning, "auto");
2725 assert_eq!(receipt.effective_reasoning, "max");
2726 assert_eq!(receipt.selection_source, "fleet_router");
2727 assert!(!receipt.content_hash.is_empty());
2728
2729 // The service is labelled as a service, with its exact route and the
2730 // configured requested → provider-effective call reasoning.
2731 let router = receipt.router.as_ref().expect("router identity");
2732 assert_eq!(router.service_kind, "reasoning_router");
2733 assert_eq!(router.qualified(), "workspace/luna-low");
2734 assert_eq!(router.provider, "openai");
2735 assert_eq!(router.model, "gpt-5.6-luna");
2736 let call = router.call.as_ref().expect("call disclosure");
2737 assert_eq!(call.requested, "low");
2738 assert_eq!(call.effective, "low");
2739 assert_eq!(call.provider_effective, "low");
2740
2741 // Cross-provider inference happened (zai worker, openai router) and is
2742 // disclosed rather than implied away.
2743 assert!(receipt.cross_provider_inference);
2744 assert!(
2745 receipt.transport.contains("different provider"),
2746 "{}",
2747 receipt.transport
2748 );
2749
2750 // Disclosure without content.
2751 let disclosure = receipt.routing_summary.as_ref().expect("disclosure");
2752 assert!(disclosure.transmitted_bytes > 0);
2753 assert!(disclosure.content_hash.starts_with("sha256:"));
2754 assert!(disclosure.redacted);
2755
2756 let json = serde_json::to_string(receipt).expect("serialize");
2757 for forbidden in ["/Users/", "/home/", ".toml", "api_key", "zzz", "refactor"] {
2758 assert!(!json.contains(forbidden), "{forbidden} in {json}");
2759 }
2760
2761 // The visible line names every side and echoes no content.
2762 let line = receipt.line();
2763 for expected in [
2764 "requested=auto",
2765 "effective=max",
2766 "source=fleet_router",
2767 "reasoning_router:workspace/luna-low",
2768 "router_call_requested=low",
2769 ] {
2770 assert!(line.contains(expected), "{expected} missing from {line}");
2771 }
2772 assert!(!line.contains("refactor"), "{line}");
2773 }
2774
2775 /// A member's semantic role and its Runtime posture are separate
2776 /// facts and the receipt keeps both. An operator who named a member
2777 /// `auditor` must see `auditor` on the receipt, while the surface actually
2778 /// selected (`explore`, the fail-closed posture an undeclared role gets
2779 /// since #5575) is disclosed rather than substituted for the name.
2780 #[tokio::test]
2781 async fn a_receipt_records_the_posture_without_renaming_the_members_role() {
2782 const AUDIT_FLEET: &str = r#"
2783 name = "glm-pair"
2784 schema = "exact"
2785
2786 [[members]]
2787 id = "auditor"
2788 role = "auditor"
2789 provider = "zai"
2790 model = "glm-5"
2791 reasoning = "high"
2792 permissions = "read_only"
2793 "#;
2794 let workflow = workflow_with(None, AUDIT_FLEET);
2795 let binding = workflow
2796 .bind_member(None, Some("auditor"), full_session())
2797 .expect("bind");
2798
2799 // Enforcement uses the posture; it is not the operator's role name.
2800 assert_eq!(binding.member_role, "auditor");
2801 assert_eq!(binding.authority.posture_role, "explore");
2802
2803 let launch = workflow
2804 .route_admitted_task(&binding, "review the queue")
2805 .await
2806 .expect("launch");
2807 let receipt = &launch.receipt;
2808
2809 assert_eq!(receipt.member_role, "auditor");
2810 assert_eq!(receipt.posture_role.as_deref(), Some("explore"));
2811 let line = receipt.line();
2812 assert!(line.contains("(role auditor)"), "{line}");
2813 assert!(line.contains("posture=explore"), "{line}");
2814 }
2815 }
2816
2816 lines RUST