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