feat(workflow): add exact Fleet schema, Reasoning Router, and disclosure redaction
Introduces the Rust-owned core of Saved Fleets and the Reasoning Router as pure library modules, with no runtime call path yet. Everything here is parse/resolve/verify logic that later slices wire into the TUI. - `fleet_exact`: the exact Fleet schema. Routes are frozen — provider and model are literals, and the selector tokens that are legal elsewhere in CodeWhale (`auto`, `inherit`, `faster`, ...) are rejected by value, while `deny_unknown_fields` rejects `model_strength`/`loadout`/`model_class` as keys. Roles compare under one canonical key, so the `oracle`/`advisor` → `consultant` rename resolves in both directions. - `reasoning_router`: the saved Router service profile, addressable either as a named reference several Fleets can share or as a legacy inline router captured from an older file. - `fleet_reasoning`: requested → effective reasoning resolution, the Router call plan, and strict decision parsing. Parsing rejects trailing content, duplicate keys, and anything that would mutate a frozen route, so a malformed Router reply fails closed rather than silently re-routing. - `fleet_preflight`: endpoint identity and credential readiness, evaluated before any Router spend. - `fleet_snapshot`: content-addressed Fleet snapshots. Hashes are recomputed and validated on read, and no absolute path is ever captured. - `redaction`: the single chokepoint that strips secrets and absolute paths out of anything bound for a receipt. `named_fleet` gains `FleetSearchRoot`/`FleetDocument`/`load_by_name` and is committed together with the leaf modules rather than ahead of them: it imports `fleet_exact` and `fleet_snapshot`, and those import back, so the two halves do not compile apart. Two corrections to the harvested source: - `redaction` dropped a dead `arm` reset that `redact_token` overwrites before it can be read. Behavior is unchanged; the assignment only tripped `unused_assignments` under `-D warnings`. - `legacy_advisory_role_names_canonicalize_to_consultant` asserted that a lookup by the legacy spelling returns `None`, which contradicts both the module's stated contract and `member_by_role`, which canonicalizes the lookup key. The assertion now checks that both spellings land on the same member. This is a test fix, not a behavior change. Tests: 222 workflow lib tests, 16 exact_fleet_workflow integration tests.
This commit is contained in:
Generated
+1
@@ -1169,6 +1169,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
//! Worker route **preflight**: everything about a route that must be true and
|
||||
//! frozen *before* a Workflow starts, and certainly before any Router is
|
||||
//! asked anything.
|
||||
//!
|
||||
//! An exact Fleet's promise is that the saved provider/model is the one that
|
||||
//! runs. That promise is only worth something if it is *checked* at the point
|
||||
//! the run is admitted, not discovered at the first API call:
|
||||
//!
|
||||
//! - **Provider identity** — the exact configured provider key and its kind.
|
||||
//! - **Canonical wire model** — the model string that will actually be placed
|
||||
//! on the request. Receipt and child spawn must use *this* value, not the
|
||||
//! file's spelling of it, or the receipt describes a request nobody made.
|
||||
//! - **Credential / readiness** — decided **locally**, from configuration. No
|
||||
//! live probe: a preflight that hits the network would spend money and leak
|
||||
//! the fact of the run before the operator's gates have even been evaluated.
|
||||
//! Keyless local providers (`vllm`, `ollama`, `sglang`, …) are
|
||||
//! [`CredentialReadiness::KeylessLocal`] and are perfectly valid.
|
||||
//! - **Endpoint identity** — a non-secret label for *where* the request goes,
|
||||
//! so two members pointed at different deployments of the same model id are
|
||||
//! distinguishable on a receipt. Never a full URL with credentials in it.
|
||||
//! - **Reasoning capability** — what the route can truthfully express, derived
|
||||
//! once here so a later launch cannot invent one.
|
||||
//!
|
||||
//! Everything in this module is a plain value with no clock, no filesystem, and
|
||||
//! no network. The host supplies the facts; this crate defines their shape and
|
||||
//! the invariants over them.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::fleet_exact::FrozenRoute;
|
||||
use crate::fleet_reasoning::ReasoningCapability;
|
||||
|
||||
/// Whether a route can be called at all, decided from local configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum CredentialReadiness {
|
||||
/// A credential for this provider is configured on this machine.
|
||||
Configured,
|
||||
/// The provider is a local, keyless endpoint. Valid, and not a downgrade.
|
||||
KeylessLocal,
|
||||
/// No credential is configured. The route cannot run.
|
||||
Missing { detail: String },
|
||||
}
|
||||
|
||||
impl CredentialReadiness {
|
||||
#[must_use]
|
||||
pub const fn is_ready(&self) -> bool {
|
||||
matches!(self, Self::Configured | Self::KeylessLocal)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Configured => "configured",
|
||||
Self::KeylessLocal => "keyless_local",
|
||||
Self::Missing { .. } => "missing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-secret identity for the endpoint a route talks to.
|
||||
///
|
||||
/// Deliberately **not** a base URL: a configured base URL can carry a token in
|
||||
/// its path or query, and receipts are durable. Host plus a coarse path label is
|
||||
/// enough to tell two deployments apart, which is the only thing this is for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EndpointIdentity {
|
||||
/// Host (and port, when non-default), lowercased. Never credentials.
|
||||
pub host: String,
|
||||
/// Whether the endpoint resolves to loopback / a private address.
|
||||
pub local: bool,
|
||||
}
|
||||
|
||||
impl EndpointIdentity {
|
||||
/// Build an endpoint identity from a base URL, keeping only the host.
|
||||
///
|
||||
/// Parsing is deliberately minimal and dependency-free: strip the scheme,
|
||||
/// drop anything before an `@` (which is exactly where a credential would
|
||||
/// live), then keep the authority up to the first `/`.
|
||||
#[must_use]
|
||||
pub fn from_base_url(base_url: &str) -> Self {
|
||||
let without_scheme = base_url
|
||||
.trim()
|
||||
.split_once("://")
|
||||
.map_or(base_url.trim(), |(_, rest)| rest);
|
||||
let authority = without_scheme
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
// `user:password@host` — everything before the `@` is a credential.
|
||||
let host = authority
|
||||
.rsplit_once('@')
|
||||
.map_or(authority, |(_, host)| host)
|
||||
.to_ascii_lowercase();
|
||||
let bare = host.split(':').next().unwrap_or(&host);
|
||||
let local = bare == "localhost"
|
||||
|| bare == "127.0.0.1"
|
||||
|| bare == "::1"
|
||||
|| bare.starts_with("192.168.")
|
||||
|| bare.starts_with("10.")
|
||||
|| bare.ends_with(".local");
|
||||
Self { host, local }
|
||||
}
|
||||
|
||||
/// The compact receipt form.
|
||||
#[must_use]
|
||||
pub fn label(&self) -> String {
|
||||
if self.local {
|
||||
format!("{} (local)", self.host)
|
||||
} else {
|
||||
self.host.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One worker's route, fully preflighted and frozen.
|
||||
///
|
||||
/// Constructed once, at Workflow start. A launch reads it; nothing rewrites
|
||||
/// it. The `wire_model` here is the single source of truth for both the
|
||||
/// receipt and the child spawn.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PreflightedRoute {
|
||||
/// The member this route belongs to.
|
||||
pub member_id: String,
|
||||
/// Exact configured provider key, as the operator named it.
|
||||
pub provider_id: String,
|
||||
/// Provider kind (`zai`, `openai`, `deepseek`, `vllm`, …).
|
||||
pub provider_kind: String,
|
||||
/// The model id exactly as saved in the Fleet file.
|
||||
pub declared_model: String,
|
||||
/// The canonical model string that will be placed on the wire. Receipt and
|
||||
/// child spawn both use this.
|
||||
pub wire_model: String,
|
||||
/// Where the request goes.
|
||||
pub endpoint: EndpointIdentity,
|
||||
/// Locally decided readiness. Never a live probe.
|
||||
pub credential: CredentialReadiness,
|
||||
/// What the route can truthfully express about reasoning.
|
||||
pub capability: ReasoningCapability,
|
||||
}
|
||||
|
||||
impl PreflightedRoute {
|
||||
/// The frozen provider/model pair, in canonical wire form.
|
||||
///
|
||||
/// This is what a receipt records and what a child spawns with — the two
|
||||
/// cannot disagree because there is only one value.
|
||||
#[must_use]
|
||||
pub fn frozen(&self) -> FrozenRoute {
|
||||
FrozenRoute {
|
||||
provider: self.provider_id.clone(),
|
||||
model: self.wire_model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the declared model string differed from the canonical wire form.
|
||||
/// Recorded rather than hidden: `glm-5` resolving to `glm-5-20260101` is a
|
||||
/// fact the operator should be able to see on a receipt.
|
||||
#[must_use]
|
||||
pub fn model_canonicalized(&self) -> bool {
|
||||
self.declared_model != self.wire_model
|
||||
}
|
||||
|
||||
/// Fail if this route is not runnable. Called at Workflow start.
|
||||
pub fn require_ready(&self) -> Result<(), PreflightError> {
|
||||
match &self.credential {
|
||||
CredentialReadiness::Configured | CredentialReadiness::KeylessLocal => Ok(()),
|
||||
CredentialReadiness::Missing { detail } => Err(PreflightError::CredentialMissing {
|
||||
member: self.member_id.clone(),
|
||||
provider: self.provider_id.clone(),
|
||||
detail: detail.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frozen preflight for every worker in a Workflow, plus the Router.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct RoutePreflight {
|
||||
workers: Vec<PreflightedRoute>,
|
||||
router: Option<PreflightedRoute>,
|
||||
}
|
||||
|
||||
impl RoutePreflight {
|
||||
#[must_use]
|
||||
pub fn new(workers: Vec<PreflightedRoute>, router: Option<PreflightedRoute>) -> Self {
|
||||
Self { workers, router }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workers(&self) -> &[PreflightedRoute] {
|
||||
&self.workers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn router(&self) -> Option<&PreflightedRoute> {
|
||||
self.router.as_ref()
|
||||
}
|
||||
|
||||
/// The frozen route for one member id.
|
||||
#[must_use]
|
||||
pub fn worker(&self, member_id: &str) -> Option<&PreflightedRoute> {
|
||||
let key = member_id.trim().to_ascii_lowercase();
|
||||
self.workers.iter().find(|route| route.member_id == key)
|
||||
}
|
||||
|
||||
/// Fail unless every worker route is runnable.
|
||||
///
|
||||
/// Called before a Workflow is allowed to start, so a member with no
|
||||
/// credential is a startup error rather than a first-task surprise.
|
||||
pub fn require_all_ready(&self) -> Result<(), PreflightError> {
|
||||
for route in &self.workers {
|
||||
route.require_ready()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether any worker route talks to a different provider than the Router.
|
||||
/// This is what a receipt discloses as cross-provider inference.
|
||||
#[must_use]
|
||||
pub fn crosses_providers(&self, member_id: &str) -> bool {
|
||||
match (self.worker(member_id), self.router()) {
|
||||
(Some(worker), Some(router)) => worker.provider_id != router.provider_id,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum PreflightError {
|
||||
#[error(
|
||||
"fleet member `{member}` is pinned to provider `{provider}`, which does not resolve to a \
|
||||
configured provider: {detail}"
|
||||
)]
|
||||
ProviderUnresolved {
|
||||
member: String,
|
||||
provider: String,
|
||||
detail: String,
|
||||
},
|
||||
#[error(
|
||||
"fleet member `{member}` is pinned to model `{model}` on provider `{provider}`, which is \
|
||||
not a valid route: {detail}"
|
||||
)]
|
||||
ModelUnresolved {
|
||||
member: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
detail: String,
|
||||
},
|
||||
#[error(
|
||||
"fleet member `{member}` cannot run: provider `{provider}` has no credential configured \
|
||||
on this machine ({detail}). This is decided locally — no provider was contacted. Keyless \
|
||||
local providers do not need one."
|
||||
)]
|
||||
CredentialMissing {
|
||||
member: String,
|
||||
provider: String,
|
||||
detail: String,
|
||||
},
|
||||
#[error(
|
||||
"cannot determine what reasoning control provider `{provider}` actually expresses for \
|
||||
model `{model}`: {detail}. An exact fleet fails closed here rather than claiming a \
|
||||
capability it did not verify."
|
||||
)]
|
||||
CapabilityUnknown {
|
||||
provider: String,
|
||||
model: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn route(member: &str, provider: &str, wire: &str) -> PreflightedRoute {
|
||||
PreflightedRoute {
|
||||
member_id: member.to_string(),
|
||||
provider_id: provider.to_string(),
|
||||
provider_kind: provider.to_string(),
|
||||
declared_model: wire.to_string(),
|
||||
wire_model: wire.to_string(),
|
||||
endpoint: EndpointIdentity::from_base_url("https://api.z.ai/api/paas/v4"),
|
||||
credential: CredentialReadiness::Configured,
|
||||
capability: ReasoningCapability::tiered(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endpoint_identity_keeps_the_host_and_drops_credentials() {
|
||||
let identity =
|
||||
EndpointIdentity::from_base_url("https://user:sk-secret@api.z.ai/api/paas/v4");
|
||||
assert_eq!(identity.host, "api.z.ai");
|
||||
assert!(!identity.local);
|
||||
assert!(!identity.label().contains("sk-secret"));
|
||||
assert!(!identity.label().contains('/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_and_private_endpoints_are_marked_local() {
|
||||
for url in [
|
||||
"http://127.0.0.1:8000/v1",
|
||||
"http://localhost:11434",
|
||||
"http://192.168.1.20:8000/v1",
|
||||
"http://box.local/v1",
|
||||
] {
|
||||
let identity = EndpointIdentity::from_base_url(url);
|
||||
assert!(identity.local, "{url} must be local");
|
||||
assert!(identity.label().ends_with("(local)"));
|
||||
}
|
||||
assert!(!EndpointIdentity::from_base_url("https://api.openai.com/v1").local);
|
||||
}
|
||||
|
||||
/// The receipt and the child spawn must not be able to disagree, so there
|
||||
/// is exactly one canonical wire model and both read it.
|
||||
#[test]
|
||||
fn the_frozen_route_uses_the_canonical_wire_model() {
|
||||
let mut preflighted = route("implementer", "zai", "glm-5");
|
||||
preflighted.wire_model = "glm-5-20260101".to_string();
|
||||
|
||||
assert_eq!(preflighted.frozen().model, "glm-5-20260101");
|
||||
assert_eq!(preflighted.frozen().provider, "zai");
|
||||
assert!(preflighted.model_canonicalized());
|
||||
assert_eq!(preflighted.declared_model, "glm-5");
|
||||
}
|
||||
|
||||
/// Keyless local providers are first-class: readiness is about whether the
|
||||
/// route can run, not about whether a key exists.
|
||||
#[test]
|
||||
fn keyless_local_providers_are_ready() {
|
||||
let mut local = route("worker", "vllm", "qwen3");
|
||||
local.credential = CredentialReadiness::KeylessLocal;
|
||||
local.endpoint = EndpointIdentity::from_base_url("http://127.0.0.1:8000/v1");
|
||||
|
||||
assert!(local.credential.is_ready());
|
||||
local.require_ready().expect("keyless local is valid");
|
||||
assert_eq!(local.credential.as_str(), "keyless_local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_credential_fails_the_workflow_locally() {
|
||||
let mut route = route("implementer", "zai", "glm-5");
|
||||
route.credential = CredentialReadiness::Missing {
|
||||
detail: "no ZAI_API_KEY".to_string(),
|
||||
};
|
||||
|
||||
let preflight = RoutePreflight::new(vec![route], None);
|
||||
let err = preflight
|
||||
.require_all_ready()
|
||||
.expect_err("a member with no credential must not start");
|
||||
assert!(matches!(err, PreflightError::CredentialMissing { .. }));
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("decided locally"),
|
||||
"the error must say no provider was contacted: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_provider_inference_is_detectable_from_the_preflight() {
|
||||
let preflight = RoutePreflight::new(
|
||||
vec![route("implementer", "zai", "glm-5")],
|
||||
Some(route("router", "openai", "gpt-5.6-luna")),
|
||||
);
|
||||
assert!(preflight.crosses_providers("implementer"));
|
||||
|
||||
let same = RoutePreflight::new(
|
||||
vec![route("implementer", "zai", "glm-5")],
|
||||
Some(route("router", "zai", "glm-5-turbo")),
|
||||
);
|
||||
assert!(!same.crosses_providers("implementer"));
|
||||
|
||||
// With no router, nothing crosses.
|
||||
let none = RoutePreflight::new(vec![route("implementer", "zai", "glm-5")], None);
|
||||
assert!(!none.crosses_providers("implementer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preflight_serializes_without_secrets_or_paths() {
|
||||
let preflight = RoutePreflight::new(
|
||||
vec![route("implementer", "zai", "glm-5")],
|
||||
Some(route("router", "openai", "gpt-5.6-luna")),
|
||||
);
|
||||
let json = serde_json::to_string(&preflight).expect("serialize");
|
||||
let lowered = json.to_ascii_lowercase();
|
||||
for forbidden in [
|
||||
"api_key", "secret", "bearer", "base_url", "/users/", "https://",
|
||||
] {
|
||||
assert!(!lowered.contains(forbidden), "{forbidden} in {json}");
|
||||
}
|
||||
let back: RoutePreflight = serde_json::from_str(&json).expect("round-trip");
|
||||
assert_eq!(back, preflight);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,874 @@
|
||||
//! Immutable Fleet snapshot taken at Workflow start.
|
||||
//!
|
||||
//! A saved Fleet is editable; a *running* Workflow is not. At start we capture
|
||||
//! a secret-free, durable value containing the qualified Fleet identity, the
|
||||
//! schema kind/revision/hash, the exact members, the exact routes, the
|
||||
//! reasoning policies, and the permission ceilings. Editing the saved file
|
||||
//! afterwards changes only future runs — the snapshot in flight is unaffected,
|
||||
//! because it owns copies and exposes no mutators.
|
||||
//!
|
||||
//! **No-secrets invariant**: every field here is a non-sensitive id, model
|
||||
//! string, tier label, or boolean. There is deliberately no field that could
|
||||
//! hold a credential, token, or base URL.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fleet_exact::{
|
||||
ExactFleet, ExactFleetError, FrozenRoute, PermissionCeiling, RequestedReasoning,
|
||||
canonical_member_key, canonical_role_key,
|
||||
};
|
||||
use crate::named_fleet::{FleetDocument, FleetSchema};
|
||||
use crate::reasoning_router::CapturedReasoningRouter;
|
||||
|
||||
/// A Fleet identity qualified by where the definition came from.
|
||||
///
|
||||
/// Deliberately **path-free**. An absolute filesystem path in a durable receipt
|
||||
/// leaks the operator's home directory, username, and machine layout into
|
||||
/// journals and events that travel further than the machine that wrote them.
|
||||
/// `origin/name` plus the schema/content hashes identify a definition precisely
|
||||
/// enough to compare two runs, without any of that. Local diagnostic errors
|
||||
/// (fleet not found, ambiguous fleet) still name paths — those are read on the
|
||||
/// machine that produced them and never persisted onto a receipt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QualifiedFleetId {
|
||||
/// Fleet name as declared in the file.
|
||||
pub name: String,
|
||||
/// Non-secret origin label, e.g. `workspace` or `codewhale_home`.
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
impl QualifiedFleetId {
|
||||
/// `origin/name` — the stable display form.
|
||||
#[must_use]
|
||||
pub fn qualified(&self) -> String {
|
||||
format!("{}/{}", self.origin, self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// One member as frozen into the snapshot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FleetSnapshotMember {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
/// The exact route, frozen before any reasoning resolution.
|
||||
pub route: FrozenRoute,
|
||||
/// The reasoning policy the member requested (not the effective tier —
|
||||
/// that is resolved per run and recorded on the receipt).
|
||||
pub requested_reasoning: RequestedReasoning,
|
||||
pub permissions: PermissionCeiling,
|
||||
}
|
||||
|
||||
/// The Reasoning Router service a snapshot is attached to.
|
||||
///
|
||||
/// This is [`CapturedReasoningRouter`] under its historic name — the Router is
|
||||
/// no longer a Fleet member, so the alias exists only to keep older call sites
|
||||
/// and serialized shapes readable.
|
||||
pub type FleetSnapshotRouter = CapturedReasoningRouter;
|
||||
|
||||
/// A legacy fleet's role → profile binding, recorded for provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FleetSnapshotLegacyRole {
|
||||
pub role: String,
|
||||
pub profile: String,
|
||||
}
|
||||
|
||||
/// The immutable value captured at Workflow start.
|
||||
///
|
||||
/// Fields are private and there are no setters: once captured, the only way to
|
||||
/// change a snapshot is to take a new one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FleetSnapshot {
|
||||
fleet: QualifiedFleetId,
|
||||
schema_kind: String,
|
||||
schema_revision: u32,
|
||||
/// SHA-256 of the fleet definition bytes.
|
||||
schema_hash: String,
|
||||
/// SHA-256 over the captured members/routes/policies themselves, so two
|
||||
/// snapshots can be compared without re-reading the source file.
|
||||
content_hash: String,
|
||||
members: Vec<FleetSnapshotMember>,
|
||||
/// The attached Reasoning Router service, if this Fleet references one.
|
||||
/// Resolved by the host (which owns the search roots) and handed in, so a
|
||||
/// snapshot stays a pure value with no loader inside it.
|
||||
router: Option<FleetSnapshotRouter>,
|
||||
legacy_roles: Vec<FleetSnapshotLegacyRole>,
|
||||
/// Caller-supplied timestamp; this crate has no clock.
|
||||
captured_at: String,
|
||||
}
|
||||
|
||||
impl FleetSnapshot {
|
||||
/// Capture a snapshot from a parsed fleet document and an already-resolved
|
||||
/// Reasoning Router service.
|
||||
///
|
||||
/// Exact rosters are **revalidated here**, not trusted. `ExactFleet` is
|
||||
/// public and `Deserialize`, so a document can reach this point without
|
||||
/// having passed the TOML parser's invariant checks; capture is the last
|
||||
/// place to catch a duplicate role, an id/role collision, or a worker
|
||||
/// claiming the Router's identity before those become a running Workflow.
|
||||
///
|
||||
/// `router` is the captured service, whether it came from a saved reusable
|
||||
/// profile or was normalized out of the legacy inline form. Resolution
|
||||
/// happens in the host because it needs the fleet search roots; capture
|
||||
/// only records the result.
|
||||
pub fn capture(
|
||||
fleet: QualifiedFleetId,
|
||||
document: &FleetDocument,
|
||||
captured_at: impl Into<String>,
|
||||
router: Option<CapturedReasoningRouter>,
|
||||
) -> Result<Self, ExactFleetError> {
|
||||
let (members, legacy_roles) = match document.schema() {
|
||||
FleetSchema::Exact(exact) => {
|
||||
exact.validate()?;
|
||||
(exact_members(exact), Vec::new())
|
||||
}
|
||||
FleetSchema::Legacy(legacy) => (
|
||||
Vec::new(),
|
||||
legacy
|
||||
.roles
|
||||
.iter()
|
||||
.map(|(role, profile)| FleetSnapshotLegacyRole {
|
||||
role: role.clone(),
|
||||
profile: profile.clone(),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
let mut snapshot = Self {
|
||||
fleet,
|
||||
schema_kind: document.schema_kind().to_string(),
|
||||
schema_revision: document.schema_revision(),
|
||||
schema_hash: document.source_hash().to_string(),
|
||||
content_hash: String::new(),
|
||||
members,
|
||||
router,
|
||||
legacy_roles,
|
||||
captured_at: captured_at.into(),
|
||||
};
|
||||
snapshot.content_hash = snapshot.compute_content_hash();
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Recompute the canonical content hash and reject a snapshot whose
|
||||
/// recorded hash does not describe its own contents.
|
||||
///
|
||||
/// `FleetSnapshot` is `Deserialize` and its `content_hash` is an ordinary
|
||||
/// field, so a snapshot can reach a launch without ever having passed
|
||||
/// [`Self::capture`] — through a replay file, a cache, or an IPC hop. That
|
||||
/// hash is then stamped onto the durable receipt as the evidence that a run
|
||||
/// matched a saved definition, so an unverified one is not weak evidence but
|
||||
/// *false* evidence: it asserts a definition the members may not describe.
|
||||
///
|
||||
/// Call this before anything durable or costly happens. It is cheap (one
|
||||
/// canonical serialization plus a SHA-256) and it is the only thing standing
|
||||
/// between a tampered or migrated snapshot and a receipt that vouches for
|
||||
/// it.
|
||||
pub fn verify_content_hash(&self) -> Result<(), ExactFleetError> {
|
||||
let recomputed = self.compute_content_hash();
|
||||
if recomputed == self.content_hash {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ExactFleetError::ContentHashMismatch {
|
||||
fleet: self.fleet.qualified(),
|
||||
recorded: self.content_hash.clone(),
|
||||
recomputed,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`Self::verify_content_hash`], as a guard that yields the snapshot.
|
||||
///
|
||||
/// Exists so a load path cannot verify and then accidentally go on to use a
|
||||
/// *different* value: the only thing this returns is the snapshot it just
|
||||
/// checked.
|
||||
pub fn into_verified(self) -> Result<Self, ExactFleetError> {
|
||||
self.verify_content_hash()?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn compute_content_hash(&self) -> String {
|
||||
// Hash only the captured shape, not the timestamp: two Workflows
|
||||
// started from the same saved Fleet must agree.
|
||||
#[derive(Serialize)]
|
||||
struct Shape<'a> {
|
||||
fleet: &'a QualifiedFleetId,
|
||||
schema_kind: &'a str,
|
||||
schema_revision: u32,
|
||||
schema_hash: &'a str,
|
||||
members: &'a [FleetSnapshotMember],
|
||||
router: &'a Option<FleetSnapshotRouter>,
|
||||
legacy_roles: &'a [FleetSnapshotLegacyRole],
|
||||
}
|
||||
|
||||
let shape = Shape {
|
||||
fleet: &self.fleet,
|
||||
schema_kind: &self.schema_kind,
|
||||
schema_revision: self.schema_revision,
|
||||
schema_hash: &self.schema_hash,
|
||||
members: &self.members,
|
||||
router: &self.router,
|
||||
legacy_roles: &self.legacy_roles,
|
||||
};
|
||||
let encoded = serde_json::to_vec(&shape).expect("snapshot shape is serializable");
|
||||
crate::named_fleet::sha256_label(&encoded)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fleet(&self) -> &QualifiedFleetId {
|
||||
&self.fleet
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn schema_kind(&self) -> &str {
|
||||
&self.schema_kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn schema_revision(&self) -> u32 {
|
||||
self.schema_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn schema_hash(&self) -> &str {
|
||||
&self.schema_hash
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn content_hash(&self) -> &str {
|
||||
&self.content_hash
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn members(&self) -> &[FleetSnapshotMember] {
|
||||
&self.members
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn router(&self) -> Option<&FleetSnapshotRouter> {
|
||||
self.router.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn legacy_roles(&self) -> &[FleetSnapshotLegacyRole] {
|
||||
&self.legacy_roles
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn captured_at(&self) -> &str {
|
||||
&self.captured_at
|
||||
}
|
||||
|
||||
/// Look up a member by its **member id** — what addresses a roster entry.
|
||||
#[must_use]
|
||||
pub fn member(&self, id: &str) -> Option<&FleetSnapshotMember> {
|
||||
let key = canonical_member_key(id);
|
||||
self.members
|
||||
.iter()
|
||||
.find(|member| canonical_member_key(&member.id) == key)
|
||||
}
|
||||
|
||||
/// Look up a member by its **semantic role** — what gates, handoffs, and
|
||||
/// records use. Kept separate from id lookup so a task can carry a
|
||||
/// meaningful role while the runtime resolves a distinct profile id.
|
||||
///
|
||||
/// Both sides resolve through [`canonical_role_key`], so a snapshot frozen
|
||||
/// from a Fleet saved under a renamed role is still addressable by a gate or
|
||||
/// handoff that spells the role the old way.
|
||||
#[must_use]
|
||||
pub fn member_by_role(&self, role: &str) -> Option<&FleetSnapshotMember> {
|
||||
let key = canonical_role_key(role);
|
||||
self.members
|
||||
.iter()
|
||||
.find(|member| canonical_role_key(&member.role) == key)
|
||||
}
|
||||
|
||||
/// Look up by id first, then by role. Roster invariants forbid an id/role
|
||||
/// collision, so this can never be order-dependent.
|
||||
#[must_use]
|
||||
pub fn member_by_id_or_role(&self, id_or_role: &str) -> Option<&FleetSnapshotMember> {
|
||||
self.member(id_or_role)
|
||||
.or_else(|| self.member_by_role(id_or_role))
|
||||
}
|
||||
|
||||
/// Whether any frozen member requested `auto` reasoning — i.e. whether this
|
||||
/// Workflow needs a working Reasoning Router at all.
|
||||
#[must_use]
|
||||
pub fn has_auto_member(&self) -> bool {
|
||||
self.members
|
||||
.iter()
|
||||
.any(|member| member.requested_reasoning.is_auto())
|
||||
}
|
||||
|
||||
/// Ids of the members that requested `auto`, for a startup error that names
|
||||
/// who actually needs the Router.
|
||||
#[must_use]
|
||||
pub fn auto_member_ids(&self) -> Vec<String> {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|member| member.requested_reasoning.is_auto())
|
||||
.map(|member| member.id.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_members(exact: &ExactFleet) -> Vec<FleetSnapshotMember> {
|
||||
exact
|
||||
.members
|
||||
.iter()
|
||||
.map(|member| FleetSnapshotMember {
|
||||
id: canonical_member_key(&member.id),
|
||||
// The snapshot is what every receipt is built from, so it records
|
||||
// the *canonical* role even when the saved file used a renamed one.
|
||||
// Old files keep working (lookup resolves either spelling); new
|
||||
// receipts never print a name the current schema does not use.
|
||||
role: canonical_role_key(&member.role),
|
||||
route: member.frozen_route(),
|
||||
requested_reasoning: member.reasoning,
|
||||
permissions: member.permissions,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verify a snapshot that arrived from anywhere other than [`FleetSnapshot::capture`].
|
||||
///
|
||||
/// The free function exists for load/deserialize seams that hold a snapshot by
|
||||
/// reference and only need the yes/no answer — a durable-write guard, a replay
|
||||
/// loader, a cache read. It is the same check as
|
||||
/// [`FleetSnapshot::verify_content_hash`]; having a named entry point is what
|
||||
/// lets those call sites read as "verify before use" rather than as an
|
||||
/// incidental method call.
|
||||
pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> {
|
||||
snapshot.verify_content_hash()
|
||||
}
|
||||
|
||||
/// Normalize an exact Fleet's **legacy inline** Router into the captured
|
||||
/// service, if it used the prototype form.
|
||||
///
|
||||
/// A Fleet that references a saved profile resolves through
|
||||
/// [`crate::ReasoningRouterProfile::load_by_name`] instead, in the host that
|
||||
/// owns the search roots. Both paths land on the same value, which is the whole
|
||||
/// point of keeping only one runtime representation.
|
||||
#[must_use]
|
||||
pub fn captured_legacy_inline_router(exact: &ExactFleet) -> Option<CapturedReasoningRouter> {
|
||||
exact
|
||||
.legacy_inline_router()
|
||||
.map(CapturedReasoningRouter::from_legacy_inline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_hash_tests {
|
||||
use super::*;
|
||||
|
||||
const EXACT_FLEET: &str = r#"
|
||||
name = "glm-pair"
|
||||
schema = "exact"
|
||||
|
||||
[[members]]
|
||||
id = "implementer"
|
||||
role = "builder"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "high"
|
||||
permissions = "read_write"
|
||||
|
||||
[[members]]
|
||||
id = "advisor-one"
|
||||
role = "oracle"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "low"
|
||||
permissions = "analyst"
|
||||
"#;
|
||||
|
||||
fn captured() -> FleetSnapshot {
|
||||
let document = FleetDocument::parse(EXACT_FLEET).expect("parse fleet document");
|
||||
FleetSnapshot::capture(
|
||||
QualifiedFleetId {
|
||||
name: "glm-pair".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
},
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
None,
|
||||
)
|
||||
.expect("capture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freshly_captured_snapshot_verifies() {
|
||||
let snapshot = captured();
|
||||
assert!(snapshot.verify_content_hash().is_ok());
|
||||
assert!(verify_snapshot_content_hash(&snapshot).is_ok());
|
||||
assert!(snapshot.into_verified().is_ok());
|
||||
}
|
||||
|
||||
/// The round trip a replay file, a cache read, or an IPC hop performs. An
|
||||
/// untouched snapshot must survive it — otherwise the guard below would be
|
||||
/// unusable at exactly the seams it exists for.
|
||||
#[test]
|
||||
fn an_untouched_round_trip_still_verifies() {
|
||||
let snapshot = captured();
|
||||
let encoded = serde_json::to_string(&snapshot).expect("serialize");
|
||||
let decoded: FleetSnapshot = serde_json::from_str(&encoded).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded, snapshot);
|
||||
assert!(decoded.verify_content_hash().is_ok());
|
||||
}
|
||||
|
||||
/// The tamper case. A snapshot whose members were edited after capture
|
||||
/// keeps its old hash, and that hash is what a receipt would vouch for.
|
||||
/// Verification must reject it *before* any launch or durable write.
|
||||
#[test]
|
||||
fn an_edited_member_is_rejected_while_the_hash_still_claims_the_original() {
|
||||
let snapshot = captured();
|
||||
let original_hash = snapshot.content_hash().to_string();
|
||||
|
||||
let mut value = serde_json::to_value(&snapshot).expect("serialize");
|
||||
// Widen a member's route — the single most consequential edit, and the
|
||||
// one a stale hash would silently certify.
|
||||
value["members"][0]["route"]["model"] = serde_json::json!("glm-5-max");
|
||||
let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
tampered.content_hash(),
|
||||
original_hash,
|
||||
"the tamper does not touch the recorded hash — that is the point"
|
||||
);
|
||||
let error = tampered
|
||||
.verify_content_hash()
|
||||
.expect_err("a tampered snapshot must not verify");
|
||||
assert!(matches!(
|
||||
error,
|
||||
ExactFleetError::ContentHashMismatch { ref recorded, .. } if *recorded == original_hash
|
||||
));
|
||||
assert!(tampered.into_verified().is_err());
|
||||
}
|
||||
|
||||
/// Widening a permission ceiling is the tamper that matters most, since the
|
||||
/// receipt's fingerprint is computed from the ceiling this snapshot carries.
|
||||
#[test]
|
||||
fn a_widened_permission_ceiling_is_rejected() {
|
||||
let snapshot = captured();
|
||||
let mut value = serde_json::to_value(&snapshot).expect("serialize");
|
||||
value["members"][1]["permissions"]["write"] = serde_json::json!(true);
|
||||
let tampered: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(tampered.verify_content_hash().is_err());
|
||||
}
|
||||
|
||||
/// A forged hash fails the same way an edited body does: the check is a
|
||||
/// recomputation, not a presence test, so neither side can be trusted alone.
|
||||
#[test]
|
||||
fn a_forged_hash_is_rejected() {
|
||||
let snapshot = captured();
|
||||
let mut value = serde_json::to_value(&snapshot).expect("serialize");
|
||||
value["content_hash"] = serde_json::json!("0".repeat(64));
|
||||
let forged: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(forged.verify_content_hash().is_err());
|
||||
}
|
||||
|
||||
/// The migration case: a snapshot written by an older build that recorded a
|
||||
/// renamed role verbatim. Capture now canonicalizes, so the *stored* role is
|
||||
/// `consultant` and the hash covers that — an old snapshot carrying
|
||||
/// `oracle` cannot pass verification and must be re-captured rather than
|
||||
/// quietly relabelled at read time.
|
||||
#[test]
|
||||
fn a_pre_rename_snapshot_is_rejected_rather_than_silently_relabelled() {
|
||||
let snapshot = captured();
|
||||
assert_eq!(
|
||||
snapshot.members()[1].role,
|
||||
"consultant",
|
||||
"capture records the canonical role"
|
||||
);
|
||||
|
||||
let mut value = serde_json::to_value(&snapshot).expect("serialize");
|
||||
value["members"][1]["role"] = serde_json::json!("oracle");
|
||||
let migrated: FleetSnapshot = serde_json::from_value(value).expect("deserialize");
|
||||
|
||||
assert!(migrated.verify_content_hash().is_err());
|
||||
// The alias still *resolves* — compatibility is a lookup property, not a
|
||||
// licence to accept an unverified hash.
|
||||
assert!(migrated.member_by_role("consultant").is_some());
|
||||
}
|
||||
|
||||
/// Lookup canonicalization survives capture: a snapshot frozen from a Fleet
|
||||
/// saved under the old name answers to either spelling.
|
||||
#[test]
|
||||
fn snapshot_role_lookup_accepts_both_spellings() {
|
||||
let snapshot = captured();
|
||||
|
||||
for spelling in ["consultant", "oracle", "advisor", "ORACLE"] {
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.member_by_role(spelling)
|
||||
.unwrap_or_else(|| panic!("`{spelling}` must resolve"))
|
||||
.id,
|
||||
"advisor-one"
|
||||
);
|
||||
}
|
||||
assert!(snapshot.member_by_id_or_role("oracle").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fleet_exact::ShellCeiling;
|
||||
use crate::reasoning_router::{
|
||||
LEGACY_INLINE_ROUTER_ORIGIN, REASONING_ROUTER_SERVICE_KIND, ReasoningRouterProfile,
|
||||
RouterCallReasoning,
|
||||
};
|
||||
|
||||
/// A Fleet that references a saved, reusable Router profile — the shape new
|
||||
/// Fleets use.
|
||||
const EXACT: &str = r#"
|
||||
name = "glm-pair"
|
||||
schema = "exact"
|
||||
reasoning_router = "luna-low"
|
||||
|
||||
[[members]]
|
||||
id = "implementer"
|
||||
role = "builder"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "auto"
|
||||
permissions = "read_write"
|
||||
|
||||
[[members]]
|
||||
id = "auditor"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "high"
|
||||
permissions = "read_only"
|
||||
"#;
|
||||
|
||||
/// The prototype form, retained for compatibility.
|
||||
const LEGACY_INLINE: &str = r#"
|
||||
name = "glm-pair"
|
||||
schema = "exact"
|
||||
|
||||
[[members]]
|
||||
id = "implementer"
|
||||
role = "builder"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "auto"
|
||||
permissions = "read_write"
|
||||
|
||||
[[members]]
|
||||
id = "router"
|
||||
kind = "router"
|
||||
provider = "zai"
|
||||
model = "glm-5-turbo"
|
||||
"#;
|
||||
|
||||
const LEGACY_ROLE_MAP: &str = r#"
|
||||
name = "stopship"
|
||||
description = "legacy roster"
|
||||
|
||||
[roles]
|
||||
scout = "scout"
|
||||
implementer = "builder"
|
||||
"#;
|
||||
|
||||
const LUNA: &str = r#"
|
||||
name = "luna-low"
|
||||
schema = "reasoning_router"
|
||||
provider = "openai"
|
||||
model = "gpt-5.6-luna"
|
||||
call_reasoning = "low"
|
||||
"#;
|
||||
|
||||
fn id() -> QualifiedFleetId {
|
||||
QualifiedFleetId {
|
||||
name: "glm-pair".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn luna() -> CapturedReasoningRouter {
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
|
||||
CapturedReasoningRouter::from_profile(&profile, "workspace")
|
||||
}
|
||||
|
||||
fn capture(text: &str, router: Option<CapturedReasoningRouter>) -> FleetSnapshot {
|
||||
let document = FleetDocument::parse(text).expect("parse");
|
||||
FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", router).expect("capture")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_captures_identity_schema_routes_and_ceilings() {
|
||||
let snapshot = capture(EXACT, Some(luna()));
|
||||
|
||||
assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
|
||||
assert_eq!(snapshot.schema_kind(), "exact");
|
||||
assert_eq!(snapshot.schema_revision(), 1);
|
||||
assert!(snapshot.schema_hash().starts_with("sha256:"));
|
||||
assert!(snapshot.content_hash().starts_with("sha256:"));
|
||||
|
||||
// Roles and ids are separate lookups, and both find the same member.
|
||||
let by_role = snapshot.member_by_role("builder").expect("role lookup");
|
||||
let by_id = snapshot.member("implementer").expect("id lookup");
|
||||
assert_eq!(by_role.id, by_id.id);
|
||||
assert_eq!(by_id.route.provider, "zai");
|
||||
assert_eq!(by_id.route.model, "glm-5");
|
||||
assert_eq!(by_id.requested_reasoning, RequestedReasoning::Auto);
|
||||
assert!(by_id.permissions.write);
|
||||
|
||||
// An id lookup must not answer to a role, or a task naming one would
|
||||
// silently resolve the other.
|
||||
assert!(snapshot.member("builder").is_none());
|
||||
assert!(snapshot.member_by_role("implementer").is_none());
|
||||
|
||||
assert!(snapshot.has_auto_member());
|
||||
assert_eq!(snapshot.auto_member_ids(), vec!["implementer".to_string()]);
|
||||
}
|
||||
|
||||
/// The Router is a referenced service, not a Fleet member: it holds no
|
||||
/// authority, is never dispatchable, and is not in the roster.
|
||||
#[test]
|
||||
fn the_attached_router_is_a_service_and_not_a_roster_member() {
|
||||
let snapshot = capture(EXACT, Some(luna()));
|
||||
let router = snapshot.router().expect("router service");
|
||||
|
||||
assert_eq!(router.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert_eq!(router.qualified(), "workspace/luna-low");
|
||||
assert!(!router.legacy_inline);
|
||||
assert!(!router.is_dispatchable());
|
||||
assert!(!router.dispatchable);
|
||||
assert!(router.tool_surface().is_empty());
|
||||
assert_eq!(router.route.provider, "openai");
|
||||
assert_eq!(router.route.model, "gpt-5.6-luna");
|
||||
assert_eq!(router.requested_call_reasoning, RouterCallReasoning::Low);
|
||||
assert_eq!(router.permissions.shell, ShellCeiling::None);
|
||||
assert!(!router.permissions.tools);
|
||||
assert_eq!(router.permissions.delegation_depth, 0);
|
||||
|
||||
// Not reachable through worker lookup by either id or role.
|
||||
assert!(snapshot.member("luna-low").is_none());
|
||||
assert!(snapshot.member_by_role("luna-low").is_none());
|
||||
assert!(snapshot.member_by_id_or_role("router").is_none());
|
||||
}
|
||||
|
||||
/// One saved profile, two different Fleets. The service is referenced, not
|
||||
/// owned, so both snapshots capture the identical value.
|
||||
#[test]
|
||||
fn one_router_profile_serves_two_fleets() {
|
||||
let first = capture(EXACT, Some(luna()));
|
||||
let second_text = EXACT.replace("name = \"glm-pair\"", "name = \"other-pair\"");
|
||||
let document = FleetDocument::parse(&second_text).expect("parse");
|
||||
let second = FleetSnapshot::capture(
|
||||
QualifiedFleetId {
|
||||
name: "other-pair".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
},
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
Some(luna()),
|
||||
)
|
||||
.expect("capture");
|
||||
|
||||
assert_eq!(first.router(), second.router());
|
||||
assert_ne!(first.fleet(), second.fleet());
|
||||
assert_ne!(
|
||||
first.content_hash(),
|
||||
second.content_hash(),
|
||||
"different fleets are still different snapshots"
|
||||
);
|
||||
}
|
||||
|
||||
/// The prototype inline form normalizes into the same captured service, so
|
||||
/// nothing downstream has to know which way the operator wrote it.
|
||||
#[test]
|
||||
fn a_legacy_inline_router_normalizes_into_the_same_captured_service() {
|
||||
let document = FleetDocument::parse(LEGACY_INLINE).expect("parse");
|
||||
let exact = document.exact().expect("exact");
|
||||
let captured = captured_legacy_inline_router(exact).expect("inline router");
|
||||
|
||||
assert!(captured.legacy_inline);
|
||||
assert_eq!(captured.origin, LEGACY_INLINE_ROUTER_ORIGIN);
|
||||
assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert_eq!(captured.route.model, "glm-5-turbo");
|
||||
assert_eq!(captured.requested_call_reasoning, RouterCallReasoning::Off);
|
||||
assert!(!captured.is_dispatchable());
|
||||
assert!(!captured.permissions.tools);
|
||||
|
||||
let snapshot = FleetSnapshot::capture(
|
||||
id(),
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
Some(captured.clone()),
|
||||
)
|
||||
.expect("capture");
|
||||
assert_eq!(snapshot.router(), Some(&captured));
|
||||
// The inline member is not in the roster.
|
||||
assert!(snapshot.member("router").is_none());
|
||||
assert_eq!(snapshot.members().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_saved_fleet_does_not_touch_a_running_snapshot() {
|
||||
let snapshot = capture(EXACT, Some(luna()));
|
||||
|
||||
// The operator edits the saved file mid-run: different model, different
|
||||
// reasoning, wider permissions.
|
||||
let edited = EXACT
|
||||
.replace(
|
||||
"model = \"glm-5\"\nreasoning = \"auto\"",
|
||||
"model = \"glm-4\"\nreasoning = \"off\"",
|
||||
)
|
||||
.replace("permissions = \"read_write\"", "permissions = \"full\"");
|
||||
let next = capture(&edited, Some(luna()));
|
||||
|
||||
// The in-flight snapshot is untouched.
|
||||
let member = snapshot.member("implementer").expect("member");
|
||||
assert_eq!(member.route.model, "glm-5");
|
||||
assert_eq!(member.requested_reasoning, RequestedReasoning::Auto);
|
||||
assert!(!member.permissions.network_tool);
|
||||
|
||||
// The next run sees the edit, and the hashes prove they differ.
|
||||
assert_eq!(next.member("implementer").unwrap().route.model, "glm-4");
|
||||
assert_ne!(snapshot.schema_hash(), next.schema_hash());
|
||||
assert_ne!(snapshot.content_hash(), next.content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_definitions_produce_an_identical_content_hash() {
|
||||
let document = FleetDocument::parse(EXACT).expect("parse");
|
||||
let a = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
|
||||
.expect("capture");
|
||||
// Different capture time, same fleet: the content hash must not move.
|
||||
let b = FleetSnapshot::capture(id(), &document, "2026-07-27T09:30:00Z", Some(luna()))
|
||||
.expect("capture");
|
||||
|
||||
assert_eq!(a.content_hash(), b.content_hash());
|
||||
assert_ne!(a.captured_at(), b.captured_at());
|
||||
}
|
||||
|
||||
/// Swapping the attached Router is a real change to what will run, so it
|
||||
/// must move the content hash.
|
||||
#[test]
|
||||
fn changing_the_attached_router_changes_the_content_hash() {
|
||||
let with_luna = capture(EXACT, Some(luna()));
|
||||
let without = capture(EXACT, None);
|
||||
assert_ne!(with_luna.content_hash(), without.content_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_role_map_fleets_snapshot_as_legacy() {
|
||||
let document = FleetDocument::parse(LEGACY_ROLE_MAP).expect("parse legacy");
|
||||
let snapshot = FleetSnapshot::capture(
|
||||
QualifiedFleetId {
|
||||
name: "stopship".to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
},
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
None,
|
||||
)
|
||||
.expect("capture");
|
||||
|
||||
assert_eq!(snapshot.schema_kind(), "legacy");
|
||||
assert_eq!(snapshot.schema_revision(), 0);
|
||||
assert!(snapshot.members().is_empty());
|
||||
assert!(snapshot.router().is_none());
|
||||
assert!(!snapshot.has_auto_member());
|
||||
assert_eq!(snapshot.legacy_roles().len(), 2);
|
||||
assert!(
|
||||
snapshot
|
||||
.legacy_roles()
|
||||
.iter()
|
||||
.any(|role| role.role == "implementer" && role.profile == "builder")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_serialization_carries_no_secret_shaped_fields() {
|
||||
let snapshot = capture(EXACT, Some(luna()));
|
||||
let json = serde_json::to_string(&snapshot).expect("serialize");
|
||||
let lowered = json.to_ascii_lowercase();
|
||||
|
||||
for forbidden in [
|
||||
"api_key",
|
||||
"apikey",
|
||||
"secret",
|
||||
"token",
|
||||
"bearer",
|
||||
"password",
|
||||
"base_url",
|
||||
"credential",
|
||||
"authorization",
|
||||
] {
|
||||
assert!(
|
||||
!lowered.contains(forbidden),
|
||||
"snapshot must not carry `{forbidden}`: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
// Round-trips as a durable value.
|
||||
let back: FleetSnapshot = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back, snapshot);
|
||||
}
|
||||
|
||||
/// A durable snapshot identifies its definition by qualified origin/name
|
||||
/// and by hash — never by a filesystem path, which would leak the
|
||||
/// operator's home directory and username into anything that stores it.
|
||||
#[test]
|
||||
fn a_snapshot_carries_no_filesystem_path() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
std::fs::create_dir_all(tmp.path().join("fleets")).expect("dirs");
|
||||
let path = tmp.path().join("fleets/glm-pair.toml");
|
||||
std::fs::write(&path, EXACT).expect("write");
|
||||
let document = FleetDocument::load(&path, Some("glm-pair")).expect("load from disk");
|
||||
// The document still knows where it came from, for local diagnostics.
|
||||
assert!(document.source_path().is_some());
|
||||
|
||||
let snapshot =
|
||||
FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", Some(luna()))
|
||||
.expect("capture");
|
||||
let json = serde_json::to_string(&snapshot).expect("serialize");
|
||||
|
||||
assert!(!json.contains(&tmp.path().display().to_string()), "{json}");
|
||||
for fragment in ["/Users/", "/home/", "/private/", ".toml", "\\Users\\"] {
|
||||
assert!(
|
||||
!json.contains(fragment),
|
||||
"snapshot must not carry `{fragment}`: {json}"
|
||||
);
|
||||
}
|
||||
assert_eq!(snapshot.fleet().qualified(), "workspace/glm-pair");
|
||||
assert!(snapshot.content_hash().starts_with("sha256:"));
|
||||
}
|
||||
|
||||
/// Capture is the last gate before a roster becomes a running Workflow, so
|
||||
/// a value that never saw the TOML parser must still be rejected here.
|
||||
#[test]
|
||||
fn capture_revalidates_a_roster_that_bypassed_the_parser() {
|
||||
use crate::fleet_exact::ExactMember;
|
||||
|
||||
let member = |id: &str, role: &str| ExactMember {
|
||||
id: id.to_string(),
|
||||
role: role.to_string(),
|
||||
provider: "zai".to_string(),
|
||||
model: "glm-5".to_string(),
|
||||
reasoning: RequestedReasoning::Off,
|
||||
permissions: PermissionCeiling::default(),
|
||||
};
|
||||
let smuggled = ExactFleet {
|
||||
name: "f".to_string(),
|
||||
description: None,
|
||||
schema_revision: 1,
|
||||
reasoning_router: None,
|
||||
// Two members, one role: role lookup would resolve by list order.
|
||||
members: vec![member("a", "builder"), member("b", "builder")],
|
||||
router: None,
|
||||
};
|
||||
|
||||
let document = FleetDocument::from_exact_for_tests(smuggled);
|
||||
let err = FleetSnapshot::capture(id(), &document, "2026-07-26T00:00:00Z", None)
|
||||
.expect_err("capture must revalidate");
|
||||
assert!(
|
||||
matches!(err, ExactFleetError::DuplicateRole { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,16 @@
|
||||
//! top only after their cancellation and evidence semantics are proven.
|
||||
|
||||
mod elevation;
|
||||
pub mod fleet_exact;
|
||||
pub mod fleet_preflight;
|
||||
pub mod fleet_reasoning;
|
||||
pub mod fleet_snapshot;
|
||||
mod gates;
|
||||
mod js_authoring;
|
||||
mod model_policy;
|
||||
mod named_fleet;
|
||||
pub mod reasoning_router;
|
||||
mod redaction;
|
||||
mod replay;
|
||||
mod role_resolve;
|
||||
|
||||
@@ -22,6 +28,29 @@ pub use elevation::{
|
||||
DEFAULT_HIGH_BUDGET_THRESHOLD, ElevationOptions, PlanRiskHint, WorkflowPlanElevation,
|
||||
assess_plan_risk_string, assess_workflow_elevation, is_shell_tool, is_write_tool,
|
||||
};
|
||||
pub use fleet_exact::{
|
||||
EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError, ExactMember,
|
||||
FrozenRoute, LEGACY_FLEET_SCHEMA_KIND, PermissionCeiling, ROLE_ALIASES, ROUTER_PUBLIC_ID,
|
||||
ROUTER_PUBLIC_ROLE, ReasoningTier, RequestedReasoning, RouterMember, ShellCeiling,
|
||||
canonical_member_key, canonical_role_key,
|
||||
};
|
||||
pub use fleet_preflight::{
|
||||
CredentialReadiness, EndpointIdentity, PreflightError, PreflightedRoute, RoutePreflight,
|
||||
};
|
||||
pub use fleet_reasoning::{
|
||||
EffectiveReasoning, EffectiveReasoningSource, FAITHFUL_WIRE_TIERS, FleetTaskReceipt,
|
||||
ProviderEffectiveReasoning, ProviderReasoningControl, ROUTER_CALL_REASONING,
|
||||
ROUTER_MAX_OUTPUT_TOKENS, ROUTER_REASONING_FIELD, ROUTER_SUMMARY_MAX_CHARS, ROUTING_SCOPE,
|
||||
ReasoningCapability, ReasoningResolveError, ResolvedReasoning, RouterAvailability,
|
||||
RouterCallDisclosure, RouterCallInput, RouterCallPlan, RouterDecision, RouterDecisionError,
|
||||
RouterIdentity, RoutingDisclosure, RoutingPayload, TaskShape, bounded_routing_payload,
|
||||
parse_router_decision, resolve_exact_member_reasoning, resolve_legacy_reasoning,
|
||||
router_call_plan, router_system_prompt, router_user_message, transport_disclosure,
|
||||
};
|
||||
pub use fleet_snapshot::{
|
||||
FleetSnapshot, FleetSnapshotLegacyRole, FleetSnapshotMember, FleetSnapshotRouter,
|
||||
QualifiedFleetId, captured_legacy_inline_router, verify_snapshot_content_hash,
|
||||
};
|
||||
pub use gates::{
|
||||
GateError, GateKind, GateOn, GateOnFail, GateOutcome, GateSpec, GateState, GateStatusLine,
|
||||
HandoffArtifact, LaneGateBoard, stopship_gate_pipeline,
|
||||
@@ -32,9 +61,19 @@ pub use js_authoring::{
|
||||
};
|
||||
pub use model_policy::*;
|
||||
pub use named_fleet::{
|
||||
NamedFleet, NamedFleetError, STOPSHIP_REQUIRED_ROLES, load_named_fleet, load_named_fleet_file,
|
||||
FleetDocument, FleetSchema, FleetSearchRoot, NamedFleet, NamedFleetError,
|
||||
STOPSHIP_REQUIRED_ROLES, exact_schema_revision, load_named_fleet, load_named_fleet_file,
|
||||
parse_named_fleet,
|
||||
};
|
||||
pub use reasoning_router::{
|
||||
CapturedReasoningRouter, FleetRouterRef, LEGACY_INLINE_ROUTER_ORIGIN, QualifiedRouterId,
|
||||
REASONING_ROUTER_DIR, REASONING_ROUTER_SCHEMA_KIND, REASONING_ROUTER_SERVICE_KIND,
|
||||
ReasoningRouterError, ReasoningRouterProfile, RouterCallReasoning,
|
||||
};
|
||||
pub use redaction::{
|
||||
REDACTION_ABSOLUTE_PATH, REDACTION_RELATIVE_PATH, REDACTION_SECRET, Redaction,
|
||||
redact_for_disclosure,
|
||||
};
|
||||
pub use replay::*;
|
||||
pub use role_resolve::{
|
||||
FleetRoleMap, FleetRoleResolveError, ResolvedWorkflowAgent, normalize_token,
|
||||
|
||||
@@ -3,14 +3,64 @@
|
||||
//! Format: TOML at `fleets/<name>.toml` (workspace) or
|
||||
//! `$CODEWHALE_HOME/fleets/<name>.toml`.
|
||||
//!
|
||||
//! Fleet resolves roles → profile ids only. Runtime owns tmux/worktrees.
|
||||
//! Two forms share this one store — there is no parallel fleet directory:
|
||||
//!
|
||||
//! - **Legacy**: `[roles]` maps role → AgentProfile id. Fleet resolves roles →
|
||||
//! profile ids only; Runtime owns tmux/worktrees. Legacy files declare no
|
||||
//! `schema` key, which is what makes the form explicitly detectable rather
|
||||
//! than guessed from a missing table.
|
||||
//! - **Exact**: `schema = "exact"` with fully resolved `[[members]]`. See
|
||||
//! [`crate::fleet_exact`].
|
||||
//!
|
||||
//! [`FleetDocument`] is the form-agnostic entry point: it reports which form a
|
||||
//! file is in and carries the content hash a Workflow snapshot records.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::fleet_exact::{
|
||||
EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
|
||||
LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
|
||||
};
|
||||
use crate::fleet_snapshot::QualifiedFleetId;
|
||||
|
||||
/// One labelled place fleet files are looked up.
|
||||
///
|
||||
/// The label is what makes a Fleet identity *qualified*: `workspace/glm-pair`
|
||||
/// and `codewhale_home/glm-pair` are different Fleets, and the loader refuses
|
||||
/// to guess between them for exact definitions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FleetSearchRoot {
|
||||
/// Non-secret origin label, e.g. `workspace` or `codewhale_home`.
|
||||
pub origin: String,
|
||||
/// Directory that contains a `fleets/` subdirectory.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl FleetSearchRoot {
|
||||
pub fn new(origin: impl Into<String>, root: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
origin: origin.into(),
|
||||
root: root.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `origin/name` into its parts. A bare name yields `(None, name)`.
|
||||
fn split_qualified_fleet_name(name: &str) -> (Option<&str>, &str) {
|
||||
let trimmed = name.trim();
|
||||
match trimmed.split_once('/') {
|
||||
Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
|
||||
(Some(origin.trim()), bare.trim())
|
||||
}
|
||||
_ => (None, trimmed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed named fleet file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NamedFleet {
|
||||
@@ -33,6 +83,288 @@ pub enum NamedFleetError {
|
||||
MissingRole { fleet: String, role: String },
|
||||
#[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
|
||||
NameMismatch { declared: String, expected: String },
|
||||
#[error(
|
||||
"fleet `{name}` is defined in more than one place ({}); an exact fleet must not be \
|
||||
resolved by shadowing. Name one explicitly as `origin/{name}`.",
|
||||
origins.join(", ")
|
||||
)]
|
||||
AmbiguousFleet { name: String, origins: Vec<String> },
|
||||
#[error("exact fleet `{fleet}`: {source}")]
|
||||
Exact {
|
||||
fleet: String,
|
||||
#[source]
|
||||
source: ExactFleetError,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which form a `fleets/<name>.toml` file is in.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum FleetSchema {
|
||||
/// Pre-exact role → AgentProfile id map.
|
||||
Legacy(NamedFleet),
|
||||
/// Fully resolved exact members.
|
||||
Exact(ExactFleet),
|
||||
}
|
||||
|
||||
/// A parsed fleet file plus the provenance a Workflow snapshot needs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FleetDocument {
|
||||
schema: FleetSchema,
|
||||
source: Option<PathBuf>,
|
||||
source_hash: String,
|
||||
}
|
||||
|
||||
impl FleetDocument {
|
||||
/// Parse either form. The exact form is selected by an explicit
|
||||
/// `schema = "exact"`; everything else is the legacy form.
|
||||
pub fn parse(text: &str) -> Result<Self, NamedFleetError> {
|
||||
let schema = match declared_schema_kind(text).as_deref() {
|
||||
Some(EXACT_FLEET_SCHEMA_KIND) => {
|
||||
let exact = ExactFleet::parse(text).map_err(|source| NamedFleetError::Exact {
|
||||
fleet: "<memory>".to_string(),
|
||||
source,
|
||||
})?;
|
||||
FleetSchema::Exact(exact)
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(NamedFleetError::Parse {
|
||||
path: "<memory>".into(),
|
||||
message: format!("unknown fleet schema `{other}`; expected `exact`"),
|
||||
});
|
||||
}
|
||||
None => FleetSchema::Legacy(parse_named_fleet(text)?),
|
||||
};
|
||||
Ok(Self {
|
||||
schema,
|
||||
source: None,
|
||||
source_hash: content_hash(text),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(path: &Path, expect_name: Option<&str>) -> Result<Self, NamedFleetError> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
|
||||
path: path.display().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
let mut document = Self::parse(&text).map_err(|e| match e {
|
||||
NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
|
||||
path: path.display().to_string(),
|
||||
message,
|
||||
},
|
||||
NamedFleetError::Exact { source, .. } => NamedFleetError::Exact {
|
||||
fleet: path.display().to_string(),
|
||||
source,
|
||||
},
|
||||
other => other,
|
||||
})?;
|
||||
if let Some(expected) = expect_name
|
||||
&& document.name() != expected
|
||||
{
|
||||
return Err(NamedFleetError::NameMismatch {
|
||||
declared: document.name().to_string(),
|
||||
expected: expected.to_string(),
|
||||
});
|
||||
}
|
||||
document.source = Some(path.to_path_buf());
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
/// Load a fleet document by name from labelled search roots.
|
||||
///
|
||||
/// A bare `name` that exists under more than one origin is **ambiguous**
|
||||
/// once any candidate is an exact fleet: a personal `~/.codewhale` Fleet
|
||||
/// silently shadowing (or being shadowed by) a project Fleet would change
|
||||
/// which exact provider/model actually runs, so the caller is asked for a
|
||||
/// qualified `origin/name` instead. Purely legacy collisions keep the
|
||||
/// historic first-hit-wins behavior, because a role→profile map resolves
|
||||
/// through the same profile store either way.
|
||||
///
|
||||
/// Ambiguity is decided from a `schema`-key probe, not from a full parse of
|
||||
/// every candidate: a malformed file in a *shadowed* origin must not break a
|
||||
/// legacy load that has always worked. A file whose TOML does not even
|
||||
/// parse therefore counts as legacy for this decision, and the first hit
|
||||
/// still wins — the same outcome the pre-exact loader gave.
|
||||
///
|
||||
/// Accepts `origin/name` to name one origin explicitly.
|
||||
pub fn load_by_name(
|
||||
name: &str,
|
||||
search_roots: &[FleetSearchRoot],
|
||||
) -> Result<(Self, QualifiedFleetId), NamedFleetError> {
|
||||
let (requested_origin, bare_name) = split_qualified_fleet_name(name);
|
||||
let file_name = format!("{bare_name}.toml");
|
||||
|
||||
let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
|
||||
for root in search_roots {
|
||||
if let Some(origin) = requested_origin
|
||||
&& !root.origin.eq_ignore_ascii_case(origin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let path = root.root.join("fleets").join(&file_name);
|
||||
if path.is_file() {
|
||||
candidates.push((root, path));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((first_root, first_path)) = candidates.first() else {
|
||||
return Err(NamedFleetError::NotFound(name.to_string()));
|
||||
};
|
||||
|
||||
if candidates.len() > 1 {
|
||||
// Decide ambiguity from the `schema` key alone — a cheap probe that
|
||||
// does not parse the rest of the file. Fully parsing every sibling
|
||||
// would mean a malformed *shadowed* file could fail a load that
|
||||
// legacy first-hit-wins has always satisfied, which is a regression
|
||||
// in a path the exact schema was never meant to touch. The
|
||||
// ambiguity that actually matters is "one of these is exact", and
|
||||
// the probe answers exactly that.
|
||||
let mut any_exact = false;
|
||||
for (_, path) in &candidates {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
|
||||
path: path.display().to_string(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
if declared_schema_kind(&text).is_some() {
|
||||
any_exact = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any_exact {
|
||||
return Err(NamedFleetError::AmbiguousFleet {
|
||||
name: bare_name.to_string(),
|
||||
origins: candidates
|
||||
.iter()
|
||||
.map(|(root, path)| {
|
||||
format!("{}/{bare_name} ({})", root.origin, path.display())
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
// Purely legacy collision: first hit wins, and only the first hit
|
||||
// is parsed.
|
||||
}
|
||||
|
||||
let document = Self::load(first_path, Some(bare_name))?;
|
||||
Ok((
|
||||
document,
|
||||
QualifiedFleetId {
|
||||
name: bare_name.to_string(),
|
||||
origin: first_root.origin.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> &str {
|
||||
match &self.schema {
|
||||
FleetSchema::Legacy(fleet) => &fleet.name,
|
||||
FleetSchema::Exact(fleet) => &fleet.name,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
match &self.schema {
|
||||
FleetSchema::Legacy(fleet) => fleet.description.as_deref(),
|
||||
FleetSchema::Exact(fleet) => fleet.description.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn schema(&self) -> &FleetSchema {
|
||||
&self.schema
|
||||
}
|
||||
|
||||
/// Explicit legacy detection — never inferred from a missing table.
|
||||
#[must_use]
|
||||
pub const fn is_legacy(&self) -> bool {
|
||||
matches!(self.schema, FleetSchema::Legacy(_))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn legacy(&self) -> Option<&NamedFleet> {
|
||||
match &self.schema {
|
||||
FleetSchema::Legacy(fleet) => Some(fleet),
|
||||
FleetSchema::Exact(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn exact(&self) -> Option<&ExactFleet> {
|
||||
match &self.schema {
|
||||
FleetSchema::Exact(fleet) => Some(fleet),
|
||||
FleetSchema::Legacy(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn schema_kind(&self) -> &'static str {
|
||||
match self.schema {
|
||||
FleetSchema::Legacy(_) => LEGACY_FLEET_SCHEMA_KIND,
|
||||
FleetSchema::Exact(_) => EXACT_FLEET_SCHEMA_KIND,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn schema_revision(&self) -> u32 {
|
||||
match &self.schema {
|
||||
// Legacy files carry no revision; report 0 so a snapshot can tell
|
||||
// "pre-versioned" from exact revision 1.
|
||||
FleetSchema::Legacy(_) => 0,
|
||||
FleetSchema::Exact(fleet) => fleet.schema_revision,
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 of the exact file bytes this document was parsed from.
|
||||
#[must_use]
|
||||
pub fn source_hash(&self) -> &str {
|
||||
&self.source_hash
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn source_path(&self) -> Option<&Path> {
|
||||
self.source.as_deref()
|
||||
}
|
||||
|
||||
/// Build a document around an already-constructed exact roster.
|
||||
///
|
||||
/// Test-only, and deliberately so: it is how a roster that never passed
|
||||
/// through the TOML parser reaches [`crate::FleetSnapshot::capture`], which
|
||||
/// is exactly the bypass capture-time revalidation exists to close.
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub(crate) fn from_exact_for_tests(exact: ExactFleet) -> Self {
|
||||
Self {
|
||||
schema: FleetSchema::Exact(exact),
|
||||
source: None,
|
||||
source_hash: content_hash("<constructed>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema revision an exact document is expected to declare.
|
||||
#[must_use]
|
||||
pub const fn exact_schema_revision() -> u32 {
|
||||
EXACT_FLEET_SCHEMA_REVISION
|
||||
}
|
||||
|
||||
pub(crate) fn content_hash(text: &str) -> String {
|
||||
sha256_label(text.as_bytes())
|
||||
}
|
||||
|
||||
/// `sha256:<hex>` over arbitrary bytes. Mirrors the hex helper in `replay.rs`
|
||||
/// rather than relying on a digest `LowerHex` impl.
|
||||
pub(crate) fn sha256_label(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = String::with_capacity(7 + digest.len() * 2);
|
||||
out.push_str("sha256:");
|
||||
for byte in digest.iter() {
|
||||
let _ = write!(&mut out, "{byte:02x}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Required roles for the stopship dogfood fleet (#4178).
|
||||
@@ -275,6 +607,75 @@ scout = "scout#stable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_fleet_files_still_deserialize_and_resolve_through_the_document_api() {
|
||||
let document = FleetDocument::parse(STOPSHIP_TOML).expect("legacy parse");
|
||||
|
||||
// Legacy is explicitly detectable, not inferred.
|
||||
assert!(document.is_legacy());
|
||||
assert_eq!(document.schema_kind(), "legacy");
|
||||
assert_eq!(document.schema_revision(), 0);
|
||||
assert!(document.exact().is_none());
|
||||
|
||||
let legacy = document.legacy().expect("legacy body");
|
||||
legacy.validate_stopship_roles().expect("all roles");
|
||||
assert_eq!(legacy.resolve("implementer").unwrap(), "builder");
|
||||
assert_eq!(document.name(), "stopship");
|
||||
assert!(document.source_hash().starts_with("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_fleet_files_are_selected_by_an_explicit_schema_key() {
|
||||
let document = FleetDocument::parse(
|
||||
r#"
|
||||
name = "glm-pair"
|
||||
schema = "exact"
|
||||
|
||||
[[members]]
|
||||
id = "implementer"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "auto"
|
||||
|
||||
[[members]]
|
||||
id = "router"
|
||||
kind = "router"
|
||||
provider = "zai"
|
||||
model = "glm-5-turbo"
|
||||
"#,
|
||||
)
|
||||
.expect("exact parse");
|
||||
|
||||
assert!(!document.is_legacy());
|
||||
assert_eq!(document.schema_kind(), "exact");
|
||||
assert_eq!(document.schema_revision(), exact_schema_revision());
|
||||
assert!(document.legacy().is_none());
|
||||
let exact = document.exact().expect("exact body");
|
||||
assert!(exact.has_auto_member());
|
||||
// The prototype inline form still parses, and is reported as the legacy
|
||||
// inline router rather than as a second runtime concept.
|
||||
assert!(exact.legacy_inline_router().is_some());
|
||||
assert!(exact.router_ref().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_schema_key_fails_instead_of_falling_back_to_legacy() {
|
||||
let err = FleetDocument::parse("name = \"f\"\nschema = \"experimental\"\n")
|
||||
.expect_err("unknown schema must not silently parse as legacy");
|
||||
assert!(matches!(err, NamedFleetError::Parse { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_hash_follows_the_file_bytes() {
|
||||
let a = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
|
||||
let b = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
|
||||
let c = FleetDocument::parse(&STOPSHIP_TOML.replace("builder", "implementer_profile"))
|
||||
.expect("parse");
|
||||
|
||||
assert_eq!(a.source_hash(), b.source_hash());
|
||||
assert_ne!(a.source_hash(), c.source_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_workspace_fleet_file() {
|
||||
// Relative to crate CARGO_MANIFEST_DIR → repo root fleets/
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! The **Adaptive Reasoning Router** — a saved, reusable *service*, not a Fleet
|
||||
//! member.
|
||||
//!
|
||||
//! A Fleet answers *who*: the exact provider/model assignments an operator
|
||||
//! saved for each worker. A Reasoning Router answers a much smaller question,
|
||||
//! for an already frozen worker route: *how hard should this already-chosen
|
||||
//! model think on this task?* Those are different kinds of thing, so they are
|
||||
//! different kinds of value here:
|
||||
//!
|
||||
//! - A Router is **never dispatchable**. It has no role, no tools, no shell, no
|
||||
//! write authority, and no delegation budget. It cannot be named by a task.
|
||||
//! - A Router is **referenced, not embedded**. It is saved once at
|
||||
//! `routers/<name>.toml` and referenced by name from any number of Fleets, so
|
||||
//! two Fleets can share one Router configuration without duplicating it.
|
||||
//! - A Router **never changes a route**. Provider, model, member, role, tools,
|
||||
//! and permissions are all frozen before it is called and are not among the
|
||||
//! things it is allowed to answer.
|
||||
//!
|
||||
//! ## Cheap by construction
|
||||
//!
|
||||
//! A Router call is a per-task tax on someone's tokens, so its own reasoning is
|
||||
//! capped at [`RouterCallReasoning`] — `off` or `low`, nothing else. `medium`,
|
||||
//! `high`, and `max` are **rejected at parse time rather than silently clamped**:
|
||||
//! an operator who wrote `high` asked for something this service will not do,
|
||||
//! and quietly running at `off` while the file says `high` is exactly the kind
|
||||
//! of invisible substitution receipts exist to prevent.
|
||||
//!
|
||||
//! The reverse lie is equally forbidden. A profile that asks for `low` is
|
||||
//! *called* at `low` wherever the route can express it; nothing here forces
|
||||
//! `off` and then reports `low`. Normalization against the route's real
|
||||
//! capability is recorded on the receipt (see
|
||||
//! [`crate::fleet_reasoning::RouterCallDisclosure`]).
|
||||
//!
|
||||
//! ## Legacy inline routers
|
||||
//!
|
||||
//! The prototype form — a `[[members]]` entry with `kind = "router"` inside the
|
||||
//! Fleet file — still parses, is labelled `legacy_inline`, and is **normalized
|
||||
//! into the same [`CapturedReasoningRouter`]** the named store produces. There
|
||||
//! is one runtime representation of the service, whichever way it was written.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::fleet_exact::{FrozenRoute, PermissionCeiling, ReasoningTier, RouterMember};
|
||||
use crate::named_fleet::FleetSearchRoot;
|
||||
|
||||
/// Directory (under each search root) that holds saved Router profiles.
|
||||
pub const REASONING_ROUTER_DIR: &str = "routers";
|
||||
/// Wire value of the `schema` key that selects a Router profile document.
|
||||
pub const REASONING_ROUTER_SCHEMA_KIND: &str = "reasoning_router";
|
||||
/// Current revision of the Router profile schema.
|
||||
pub const REASONING_ROUTER_SCHEMA_REVISION: u32 = 1;
|
||||
/// Stable service label a receipt prints so the reader can tell at a glance
|
||||
/// that this is the reasoning service and not a Fleet member.
|
||||
pub const REASONING_ROUTER_SERVICE_KIND: &str = "reasoning_router";
|
||||
/// Origin recorded for a Router that was written inline in a Fleet file.
|
||||
pub const LEGACY_INLINE_ROUTER_ORIGIN: &str = "legacy_inline";
|
||||
|
||||
/// The reasoning a **Router call itself** may run at.
|
||||
///
|
||||
/// Deliberately not [`ReasoningTier`]: this type exists precisely so that
|
||||
/// `medium`/`high`/`max` are unrepresentable. A Router emits one ~15-byte JSON
|
||||
/// object; anything above `low` spends a user's tokens on thinking about a
|
||||
/// question that does not need thought.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouterCallReasoning {
|
||||
#[default]
|
||||
Off,
|
||||
Low,
|
||||
}
|
||||
|
||||
impl RouterCallReasoning {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Low => "low",
|
||||
}
|
||||
}
|
||||
|
||||
/// The concrete tier this maps onto for capability normalization.
|
||||
#[must_use]
|
||||
pub const fn tier(self) -> ReasoningTier {
|
||||
match self {
|
||||
Self::Off => ReasoningTier::Off,
|
||||
Self::Low => ReasoningTier::Low,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a configured value.
|
||||
///
|
||||
/// `medium`/`high`/`max` are a distinct, named error rather than a clamp —
|
||||
/// see the module docs.
|
||||
pub fn parse(value: &str, router: &str) -> Result<Self, ReasoningRouterError> {
|
||||
let trimmed = value.trim();
|
||||
match trimmed.to_ascii_lowercase().as_str() {
|
||||
"off" | "none" | "disabled" => Ok(Self::Off),
|
||||
"low" | "minimal" => Ok(Self::Low),
|
||||
"medium" | "mid" | "high" | "max" | "maximum" | "xhigh" => {
|
||||
Err(ReasoningRouterError::CallReasoningTooExpensive {
|
||||
router: router.to_string(),
|
||||
value: trimmed.to_string(),
|
||||
})
|
||||
}
|
||||
_ => Err(ReasoningRouterError::InvalidCallReasoning {
|
||||
router: router.to_string(),
|
||||
value: trimmed.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Router identity qualified by the origin its definition came from.
|
||||
///
|
||||
/// Path-free for the same reason [`crate::QualifiedFleetId`] is: an absolute
|
||||
/// path in a durable receipt leaks the operator's home directory.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QualifiedRouterId {
|
||||
pub name: String,
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
impl QualifiedRouterId {
|
||||
#[must_use]
|
||||
pub fn qualified(&self) -> String {
|
||||
format!("{}/{}", self.origin, self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved Router profile: one exact provider/model plus a cheap call ceiling.
|
||||
///
|
||||
/// This is *the* reusable unit. Any number of Fleets may reference the same
|
||||
/// profile by name; none of them owns it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReasoningRouterProfile {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub schema_revision: u32,
|
||||
/// Exact configured provider id.
|
||||
pub provider: String,
|
||||
/// Exact model id.
|
||||
pub model: String,
|
||||
/// What the Router's own call runs at. `off` or `low` only.
|
||||
pub call_reasoning: RouterCallReasoning,
|
||||
}
|
||||
|
||||
impl ReasoningRouterProfile {
|
||||
/// Parse a Router profile document.
|
||||
pub fn parse(text: &str) -> Result<Self, ReasoningRouterError> {
|
||||
let doc: RouterProfileToml =
|
||||
toml::from_str(text).map_err(|error| ReasoningRouterError::Parse(error.to_string()))?;
|
||||
if !doc
|
||||
.schema
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(REASONING_ROUTER_SCHEMA_KIND)
|
||||
{
|
||||
return Err(ReasoningRouterError::UnknownSchema {
|
||||
schema: doc.schema.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if doc.schema_revision != REASONING_ROUTER_SCHEMA_REVISION {
|
||||
return Err(ReasoningRouterError::UnsupportedRevision {
|
||||
revision: doc.schema_revision,
|
||||
supported: REASONING_ROUTER_SCHEMA_REVISION,
|
||||
});
|
||||
}
|
||||
let name = crate::role_resolve::normalize_token(&doc.name).ok_or_else(|| {
|
||||
ReasoningRouterError::InvalidToken {
|
||||
field: "name".to_string(),
|
||||
value: doc.name.trim().to_string(),
|
||||
}
|
||||
})?;
|
||||
let provider = exact_token(&doc.provider, &name, "provider")?;
|
||||
let model = exact_token(&doc.model, &name, "model")?;
|
||||
let call_reasoning = match doc.call_reasoning.as_deref() {
|
||||
None => RouterCallReasoning::default(),
|
||||
Some(value) => RouterCallReasoning::parse(value, &name)?,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
description: doc.description,
|
||||
schema_revision: doc.schema_revision,
|
||||
provider,
|
||||
model,
|
||||
call_reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load one profile from a labelled search root set.
|
||||
///
|
||||
/// A bare name present under more than one origin is **ambiguous**: a
|
||||
/// personal `~/.codewhale` Router silently shadowing a project Router would
|
||||
/// change which provider sees every task's routing summary. Naming the
|
||||
/// origin (`codewhale_home/fast`) resolves it.
|
||||
pub fn load_by_name(
|
||||
name: &str,
|
||||
search_roots: &[FleetSearchRoot],
|
||||
) -> Result<(Self, QualifiedRouterId), ReasoningRouterError> {
|
||||
let (requested_origin, bare) = split_qualified(name);
|
||||
if bare.is_empty() {
|
||||
return Err(ReasoningRouterError::InvalidToken {
|
||||
field: "router reference".to_string(),
|
||||
value: name.trim().to_string(),
|
||||
});
|
||||
}
|
||||
let file_name = format!("{bare}.toml");
|
||||
|
||||
let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
|
||||
for root in search_roots {
|
||||
if let Some(origin) = requested_origin
|
||||
&& !root.origin.eq_ignore_ascii_case(origin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let path = root.root.join(REASONING_ROUTER_DIR).join(&file_name);
|
||||
if path.is_file() {
|
||||
candidates.push((root, path));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((first_root, first_path)) = candidates.first() else {
|
||||
return Err(ReasoningRouterError::NotFound {
|
||||
name: name.trim().to_string(),
|
||||
});
|
||||
};
|
||||
if candidates.len() > 1 {
|
||||
// Unlike legacy Fleet role maps, there is no first-hit-wins fallback
|
||||
// here: every Router profile names an exact provider/model, so a
|
||||
// shadowed one always changes behavior.
|
||||
return Err(ReasoningRouterError::AmbiguousRouter {
|
||||
name: bare.to_string(),
|
||||
origins: candidates
|
||||
.iter()
|
||||
.map(|(root, _)| format!("{}/{bare}", root.origin))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
let text =
|
||||
std::fs::read_to_string(first_path).map_err(|error| ReasoningRouterError::Io {
|
||||
path: first_path.display().to_string(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let profile = Self::parse(&text)?;
|
||||
if profile.name != bare {
|
||||
return Err(ReasoningRouterError::NameMismatch {
|
||||
declared: profile.name.clone(),
|
||||
expected: bare.to_string(),
|
||||
});
|
||||
}
|
||||
let id = QualifiedRouterId {
|
||||
name: profile.name.clone(),
|
||||
origin: first_root.origin.clone(),
|
||||
};
|
||||
Ok((profile, id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Router service as frozen into a Workflow snapshot.
|
||||
///
|
||||
/// Whether it came from a named profile or from the legacy inline member, this
|
||||
/// is the single runtime representation. Nothing downstream branches on which
|
||||
/// form the operator wrote.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapturedReasoningRouter {
|
||||
/// Always [`REASONING_ROUTER_SERVICE_KIND`]. Recorded explicitly so a
|
||||
/// receipt states what kind of thing this is instead of implying it.
|
||||
#[serde(default = "default_service_kind")]
|
||||
pub service_kind: String,
|
||||
/// The Router's id: the profile name, or the inline member's id.
|
||||
pub id: String,
|
||||
/// The origin the definition came from, or [`LEGACY_INLINE_ROUTER_ORIGIN`].
|
||||
#[serde(default = "default_router_origin")]
|
||||
pub origin: String,
|
||||
/// True when this was written inline in the Fleet file rather than saved as
|
||||
/// a reusable profile.
|
||||
#[serde(default)]
|
||||
pub legacy_inline: bool,
|
||||
/// The Router's own exact provider/model.
|
||||
pub route: FrozenRoute,
|
||||
/// What the operator configured this Router's call to run at.
|
||||
#[serde(default)]
|
||||
pub requested_call_reasoning: RouterCallReasoning,
|
||||
/// Always `false`. Stated rather than implied.
|
||||
#[serde(default)]
|
||||
pub dispatchable: bool,
|
||||
/// Always [`PermissionCeiling::ROUTER`].
|
||||
#[serde(default = "router_permissions")]
|
||||
pub permissions: PermissionCeiling,
|
||||
}
|
||||
|
||||
fn default_service_kind() -> String {
|
||||
REASONING_ROUTER_SERVICE_KIND.to_string()
|
||||
}
|
||||
|
||||
fn default_router_origin() -> String {
|
||||
LEGACY_INLINE_ROUTER_ORIGIN.to_string()
|
||||
}
|
||||
|
||||
fn router_permissions() -> PermissionCeiling {
|
||||
PermissionCeiling::ROUTER
|
||||
}
|
||||
|
||||
impl CapturedReasoningRouter {
|
||||
/// Capture a saved, reusable profile.
|
||||
#[must_use]
|
||||
pub fn from_profile(profile: &ReasoningRouterProfile, origin: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service_kind: default_service_kind(),
|
||||
id: profile.name.clone(),
|
||||
origin: origin.into(),
|
||||
legacy_inline: false,
|
||||
route: FrozenRoute {
|
||||
provider: profile.provider.clone(),
|
||||
model: profile.model.clone(),
|
||||
},
|
||||
requested_call_reasoning: profile.call_reasoning,
|
||||
dispatchable: false,
|
||||
permissions: PermissionCeiling::ROUTER,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize the prototype inline form into the same captured service.
|
||||
///
|
||||
/// The inline member's own `reasoning` was a full [`ReasoningTier`]; it is
|
||||
/// mapped onto the cheap call ceiling here, and anything above `low` is
|
||||
/// rejected by the Fleet parser rather than clamped silently.
|
||||
#[must_use]
|
||||
pub fn from_legacy_inline(member: &RouterMember) -> Self {
|
||||
Self {
|
||||
service_kind: default_service_kind(),
|
||||
id: member.id.clone(),
|
||||
origin: default_router_origin(),
|
||||
legacy_inline: true,
|
||||
route: member.frozen_route(),
|
||||
requested_call_reasoning: member.call_reasoning,
|
||||
dispatchable: false,
|
||||
permissions: PermissionCeiling::ROUTER,
|
||||
}
|
||||
}
|
||||
|
||||
/// `origin/id` — the stable display form a receipt prints.
|
||||
#[must_use]
|
||||
pub fn qualified(&self) -> String {
|
||||
format!("{}/{}", self.origin, self.id)
|
||||
}
|
||||
|
||||
/// A Router is never a worker. Constant, not a policy lookup.
|
||||
#[must_use]
|
||||
pub const fn is_dispatchable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The Router's tool surface is empty, always.
|
||||
#[must_use]
|
||||
pub const fn tool_surface(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Fleet points at its Router.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum FleetRouterRef {
|
||||
/// A saved, reusable profile named by `reasoning_router = "<name>"`.
|
||||
Profile { name: String },
|
||||
/// The prototype inline `[[members]] kind = "router"` form.
|
||||
LegacyInline(Box<RouterMember>),
|
||||
}
|
||||
|
||||
fn split_qualified(name: &str) -> (Option<&str>, &str) {
|
||||
let trimmed = name.trim();
|
||||
match trimmed.split_once('/') {
|
||||
Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
|
||||
(Some(origin.trim()), bare.trim())
|
||||
}
|
||||
_ => (None, trimmed),
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_token(value: &str, router: &str, field: &str) -> Result<String, ReasoningRouterError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed
|
||||
.chars()
|
||||
.any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
|
||||
{
|
||||
return Err(ReasoningRouterError::InvalidToken {
|
||||
field: format!("{router}.{field}"),
|
||||
value: trimmed.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RouterProfileToml {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
schema: String,
|
||||
#[serde(default = "default_router_revision")]
|
||||
schema_revision: u32,
|
||||
provider: String,
|
||||
model: String,
|
||||
#[serde(default, alias = "reasoning")]
|
||||
call_reasoning: Option<String>,
|
||||
}
|
||||
|
||||
const fn default_router_revision() -> u32 {
|
||||
REASONING_ROUTER_SCHEMA_REVISION
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ReasoningRouterError {
|
||||
#[error("failed to parse reasoning router profile: {0}")]
|
||||
Parse(String),
|
||||
#[error("failed to read reasoning router profile `{path}`: {message}")]
|
||||
Io { path: String, message: String },
|
||||
#[error("reasoning router `{name}` was not found in any configured origin")]
|
||||
NotFound { name: String },
|
||||
#[error(
|
||||
"reasoning router `{name}` is defined in more than one place ({}); a router names an \
|
||||
exact provider/model, so shadowing would silently change which provider sees every \
|
||||
routing summary. Name one explicitly as `origin/{name}`.",
|
||||
origins.join(", ")
|
||||
)]
|
||||
AmbiguousRouter { name: String, origins: Vec<String> },
|
||||
#[error("unknown reasoning router schema `{schema}`; expected `reasoning_router`")]
|
||||
UnknownSchema { schema: String },
|
||||
#[error(
|
||||
"reasoning router schema revision {revision} is not supported (this build reads {supported})"
|
||||
)]
|
||||
UnsupportedRevision { revision: u32, supported: u32 },
|
||||
#[error("{field} must be a non-empty token without whitespace, quotes, or `=` (got `{value}`)")]
|
||||
InvalidToken { field: String, value: String },
|
||||
#[error("reasoning router name mismatch: file declares `{declared}`, expected `{expected}`")]
|
||||
NameMismatch { declared: String, expected: String },
|
||||
#[error(
|
||||
"reasoning router `{router}` requests call reasoning `{value}`; a router may only run at \
|
||||
`off` or `low`. This is rejected rather than clamped: a router answers one tiny JSON \
|
||||
object per task, and running it at `{value}` would spend your tokens on thinking nobody \
|
||||
asked for. Set `call_reasoning` to `off` or `low`."
|
||||
)]
|
||||
CallReasoningTooExpensive { router: String, value: String },
|
||||
#[error(
|
||||
"reasoning router `{router}` has invalid call reasoning `{value}`; expected off or low"
|
||||
)]
|
||||
InvalidCallReasoning { router: String, value: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LUNA: &str = r#"
|
||||
name = "luna-low"
|
||||
description = "GPT-5.6 Luna, called at low"
|
||||
schema = "reasoning_router"
|
||||
schema_revision = 1
|
||||
provider = "openai"
|
||||
model = "gpt-5.6-luna"
|
||||
call_reasoning = "low"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn a_profile_parses_its_exact_route_and_cheap_call_tier() {
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
|
||||
assert_eq!(profile.name, "luna-low");
|
||||
assert_eq!(profile.provider, "openai");
|
||||
assert_eq!(profile.model, "gpt-5.6-luna");
|
||||
assert_eq!(profile.call_reasoning, RouterCallReasoning::Low);
|
||||
assert_eq!(profile.schema_revision, REASONING_ROUTER_SCHEMA_REVISION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reasoning_defaults_to_off_when_unset() {
|
||||
let text = LUNA.replace("call_reasoning = \"low\"\n", "");
|
||||
let profile = ReasoningRouterProfile::parse(&text).expect("parse");
|
||||
assert_eq!(profile.call_reasoning, RouterCallReasoning::Off);
|
||||
}
|
||||
|
||||
/// The whole point of the cheap ceiling: an expensive tier is an error the
|
||||
/// operator can see, never a clamp they cannot.
|
||||
#[test]
|
||||
fn medium_high_and_max_are_rejected_not_clamped() {
|
||||
for value in ["medium", "high", "max", "xhigh"] {
|
||||
let text = LUNA.replace("\"low\"", &format!("\"{value}\""));
|
||||
let err = ReasoningRouterProfile::parse(&text)
|
||||
.expect_err("an expensive router tier must be rejected");
|
||||
assert!(
|
||||
matches!(err, ReasoningRouterError::CallReasoningTooExpensive { .. }),
|
||||
"value={value} err={err:?}"
|
||||
);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("off"), "{message}");
|
||||
assert!(message.contains("low"), "{message}");
|
||||
assert!(
|
||||
!message.contains("clamped to"),
|
||||
"the error must not describe a clamp: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_tier_is_its_own_error() {
|
||||
let text = LUNA.replace("\"low\"", "\"turbo\"");
|
||||
assert!(matches!(
|
||||
ReasoningRouterProfile::parse(&text).expect_err("garbage"),
|
||||
ReasoningRouterError::InvalidCallReasoning { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_schema_or_future_revision_fails_closed() {
|
||||
let wrong_schema = LUNA.replace("\"reasoning_router\"", "\"exact\"");
|
||||
assert!(matches!(
|
||||
ReasoningRouterProfile::parse(&wrong_schema).expect_err("schema"),
|
||||
ReasoningRouterError::UnknownSchema { .. }
|
||||
));
|
||||
|
||||
let future = LUNA.replace("schema_revision = 1", "schema_revision = 99");
|
||||
assert!(matches!(
|
||||
ReasoningRouterProfile::parse(&future).expect_err("revision"),
|
||||
ReasoningRouterError::UnsupportedRevision { revision: 99, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_captured_profile_holds_no_authority_and_is_never_dispatchable() {
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
|
||||
let captured = CapturedReasoningRouter::from_profile(&profile, "workspace");
|
||||
|
||||
assert_eq!(captured.qualified(), "workspace/luna-low");
|
||||
assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert!(!captured.legacy_inline);
|
||||
assert!(!captured.is_dispatchable());
|
||||
assert!(captured.tool_surface().is_empty());
|
||||
assert!(!captured.permissions.tools);
|
||||
assert!(!captured.permissions.write);
|
||||
assert!(!captured.permissions.network_tool);
|
||||
assert_eq!(captured.permissions.delegation_depth, 0);
|
||||
}
|
||||
|
||||
/// One saved profile, two Fleets. The service is referenced, not owned.
|
||||
#[test]
|
||||
fn one_saved_profile_serves_more_than_one_fleet() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
std::fs::create_dir_all(tmp.path().join(REASONING_ROUTER_DIR)).expect("dir");
|
||||
std::fs::write(
|
||||
tmp.path().join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA,
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
|
||||
let (first, first_id) =
|
||||
ReasoningRouterProfile::load_by_name("luna-low", &roots).expect("load");
|
||||
let (second, second_id) =
|
||||
ReasoningRouterProfile::load_by_name("workspace/luna-low", &roots).expect("qualified");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first_id, second_id);
|
||||
assert_eq!(first_id.qualified(), "workspace/luna-low");
|
||||
|
||||
// Two independent captures of the same saved service agree exactly.
|
||||
let a = CapturedReasoningRouter::from_profile(&first, &first_id.origin);
|
||||
let b = CapturedReasoningRouter::from_profile(&second, &second_id.origin);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
/// Bare-name ambiguity across origins must fail; a qualified origin works.
|
||||
#[test]
|
||||
fn a_bare_name_defined_in_two_origins_is_ambiguous_until_qualified() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let home = tmp.path().join("home");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
for root in [&home, &workspace] {
|
||||
std::fs::create_dir_all(root.join(REASONING_ROUTER_DIR)).expect("dir");
|
||||
}
|
||||
std::fs::write(
|
||||
home.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA.replace("gpt-5.6-luna", "gpt-5.6-luna-mini"),
|
||||
)
|
||||
.expect("home");
|
||||
std::fs::write(
|
||||
workspace.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let roots = vec![
|
||||
FleetSearchRoot::new("codewhale_home", &home),
|
||||
FleetSearchRoot::new("workspace", &workspace),
|
||||
];
|
||||
|
||||
let err = ReasoningRouterProfile::load_by_name("luna-low", &roots)
|
||||
.expect_err("bare name must not be resolved by shadowing");
|
||||
assert!(
|
||||
matches!(err, ReasoningRouterError::AmbiguousRouter { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("codewhale_home"), "{message}");
|
||||
assert!(message.contains("workspace"), "{message}");
|
||||
|
||||
let (workspace_profile, id) =
|
||||
ReasoningRouterProfile::load_by_name("workspace/luna-low", &roots).expect("qualified");
|
||||
assert_eq!(id.qualified(), "workspace/luna-low");
|
||||
assert_eq!(workspace_profile.model, "gpt-5.6-luna");
|
||||
|
||||
let (home_profile, home_id) =
|
||||
ReasoningRouterProfile::load_by_name("codewhale_home/luna-low", &roots)
|
||||
.expect("qualified");
|
||||
assert_eq!(home_id.qualified(), "codewhale_home/luna-low");
|
||||
assert_eq!(home_profile.model, "gpt-5.6-luna-mini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_profile_is_a_named_error() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
|
||||
assert!(matches!(
|
||||
ReasoningRouterProfile::load_by_name("nope", &roots).expect_err("missing"),
|
||||
ReasoningRouterError::NotFound { .. }
|
||||
));
|
||||
}
|
||||
|
||||
/// A captured router serializes into a durable snapshot with no secrets and
|
||||
/// no paths, and older records without the newer fields still read.
|
||||
#[test]
|
||||
fn a_captured_router_is_durable_and_backward_compatible() {
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
|
||||
let captured = CapturedReasoningRouter::from_profile(&profile, "workspace");
|
||||
let json = serde_json::to_string(&captured).expect("serialize");
|
||||
let lowered = json.to_ascii_lowercase();
|
||||
for forbidden in ["api_key", "secret", "token", "bearer", "/users/", ".toml"] {
|
||||
assert!(!lowered.contains(forbidden), "{forbidden} in {json}");
|
||||
}
|
||||
let back: CapturedReasoningRouter = serde_json::from_str(&json).expect("round-trip");
|
||||
assert_eq!(back, captured);
|
||||
|
||||
let older = r#"{"id":"router","route":{"provider":"zai","model":"glm-5-turbo"}}"#;
|
||||
let legacy: CapturedReasoningRouter = serde_json::from_str(older).expect("serde defaults");
|
||||
assert_eq!(legacy.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert_eq!(legacy.origin, LEGACY_INLINE_ROUTER_ORIGIN);
|
||||
assert_eq!(legacy.requested_call_reasoning, RouterCallReasoning::Off);
|
||||
assert!(!legacy.dispatchable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
//! Redaction for anything that can reach a log, a span, or a durable receipt.
|
||||
//!
|
||||
//! Two classes of content are stripped before text is allowed to leave the
|
||||
//! process — either onto a provider's wire or into a journal:
|
||||
//!
|
||||
//! 1. **Absolute paths.** `/Users/hunter/src/app`, `/home/x/...`, `C:\Users\…`
|
||||
//! and `~/…` carry the operator's username, home directory, and machine
|
||||
//! layout. A journal travels further than the machine that wrote it.
|
||||
//! 2. **Repo-relative paths.** `crates/tui/src/main.rs`, `./deploy.sh`,
|
||||
//! `../../secret/notes.md` — and their escaped spellings, `crates\/tui\/…`
|
||||
//! and `crates\\tui\\…`. An absolute path discloses the machine; a relative
|
||||
//! one discloses the private tree's shape, which is exactly as much as the
|
||||
//! reader of a routing summary at another provider needs to reconstruct it.
|
||||
//! The rule is deliberately conservative — see [`looks_relative`] — because
|
||||
//! the failure it must not trade for is mangling ordinary prose or a
|
||||
//! `provider/model` label.
|
||||
//! 3. **Secret-shaped tokens.** Provider keys, bearer tokens, and
|
||||
//! `SOMETHING_KEY=value` assignments. These have no business in a routing
|
||||
//! summary and must never be persisted next to one.
|
||||
//!
|
||||
//! Redaction is *recorded*, not silent: [`Redaction::kinds`] names what was
|
||||
//! removed so a receipt can disclose the fact without disclosing the content.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A redaction kind, as it appears on a disclosure. These are stable labels —
|
||||
/// receipts persist them.
|
||||
pub const REDACTION_ABSOLUTE_PATH: &str = "absolute_path";
|
||||
pub const REDACTION_RELATIVE_PATH: &str = "relative_path";
|
||||
pub const REDACTION_SECRET: &str = "secret";
|
||||
|
||||
/// Placeholder substituted for a removed absolute path.
|
||||
const PATH_PLACEHOLDER: &str = "<path>";
|
||||
/// Placeholder substituted for a removed secret-shaped token.
|
||||
const SECRET_PLACEHOLDER: &str = "<redacted>";
|
||||
|
||||
/// Case-insensitive substrings that mark an identifier as secret-bearing.
|
||||
const SECRET_NAME_MARKERS: &[&str] = &[
|
||||
"api_key",
|
||||
"apikey",
|
||||
"secret",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"credential",
|
||||
"authorization",
|
||||
"auth_token",
|
||||
"access_key",
|
||||
"private_key",
|
||||
"session_key",
|
||||
];
|
||||
|
||||
/// Prefixes that are themselves a credential, whatever they are attached to.
|
||||
///
|
||||
/// Every entry here must be *unambiguously* a credential prefix. A prefix that
|
||||
/// is also an ordinary English word — `bearer`, `asia` — belongs nowhere near
|
||||
/// this list: it would redact prose and, worse, would report a `secret`
|
||||
/// redaction kind on a receipt that removed nothing but a word. The AWS key ids
|
||||
/// that motivated `asia`/`akia` are handled by
|
||||
/// [`looks_like_aws_access_key`], which requires the full shape.
|
||||
const SECRET_VALUE_PREFIXES: &[&str] = &[
|
||||
"sk-",
|
||||
"sk_",
|
||||
"ghp_",
|
||||
"gho_",
|
||||
"ghs_",
|
||||
"github_pat_",
|
||||
"xoxb-",
|
||||
"xoxp-",
|
||||
"xapp-",
|
||||
];
|
||||
|
||||
/// HTTP authorization scheme keywords, in their canonical HTTP capitalization.
|
||||
///
|
||||
/// These are *never* the secret — the secret is the token that follows them.
|
||||
/// Redacting the keyword and stopping there is the failure this list exists to
|
||||
/// prevent: `Authorization: Bearer <token>` would keep the token and still
|
||||
/// claim on the receipt that a secret had been removed.
|
||||
///
|
||||
/// The capitalization is stored, not normalized away, because it carries
|
||||
/// evidence: `Bearer` is HTTP syntax and `bearer` is an English noun. See
|
||||
/// [`is_canonical_auth_scheme`].
|
||||
const AUTH_SCHEMES: &[&str] = &["Bearer", "Basic", "Digest", "Token"];
|
||||
|
||||
/// The one scheme keyword whose canonical capitalization is, by itself, enough
|
||||
/// to treat the following token as a credential.
|
||||
///
|
||||
/// The asymmetry is deliberate and is the whole of the false-positive story.
|
||||
/// `Basic`, `Digest`, and `Token` are ordinary capitalized English words —
|
||||
/// "Basic auth is enabled", "Token holders vote", "Digest the results" — and
|
||||
/// arming on them would redact the next word of perfectly ordinary prose. `Bearer`
|
||||
/// capitalized is, in a technical corpus, the HTTP scheme essentially every
|
||||
/// time. So `Bearer qqq` loses `qqq` even though a three-letter lowercase token
|
||||
/// looks like nothing at all, while the other three need header context first.
|
||||
///
|
||||
/// The residual cost is stated plainly: `Bearer tokens are rotated weekly`
|
||||
/// redacts `tokens`. That is a capitalized-`Bearer` sentence, which is header
|
||||
/// syntax by shape; ordinary prose says "bearer", and lowercase never arms on
|
||||
/// its own.
|
||||
const SELF_EVIDENT_AUTH_SCHEME: &str = "Bearer";
|
||||
|
||||
/// AWS access-key-id prefixes. Matched case-sensitively and only against the
|
||||
/// full 20-character shape, so the word `Asia` is prose and `ASIA…` is a key.
|
||||
const AWS_KEY_ID_PREFIXES: &[&str] = &[
|
||||
"AKIA", "ASIA", "AGPA", "AIDA", "AROA", "ANPA", "ANVA", "ASCA", "ABIA", "ACCA",
|
||||
];
|
||||
|
||||
/// Minimum length of an AWS access key id (`AKIA` + 16).
|
||||
const AWS_KEY_ID_LEN: usize = 20;
|
||||
|
||||
/// Minimum length before a bare token is treated as a credential value.
|
||||
const CREDENTIAL_VALUE_MIN_LEN: usize = 16;
|
||||
|
||||
/// The result of redacting one string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Redaction {
|
||||
text: String,
|
||||
kinds: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Redaction {
|
||||
/// The redacted text.
|
||||
#[must_use]
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Consume the redaction, yielding the redacted text.
|
||||
#[must_use]
|
||||
pub fn into_text(self) -> String {
|
||||
self.text
|
||||
}
|
||||
|
||||
/// Whether anything was removed.
|
||||
#[must_use]
|
||||
pub fn redacted(&self) -> bool {
|
||||
!self.kinds.is_empty()
|
||||
}
|
||||
|
||||
/// Which classes of content were removed — never the content itself.
|
||||
#[must_use]
|
||||
pub fn kinds(&self) -> Vec<String> {
|
||||
self.kinds.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// How strongly the preceding tokens claim that the *next* token is a
|
||||
/// credential.
|
||||
///
|
||||
/// A credential is routinely written as two or three tokens
|
||||
/// (`Authorization: Bearer <token>`), so the decision cannot be made per token.
|
||||
/// But the evidence for "a credential follows" is not uniform, and collapsing it
|
||||
/// to a boolean is what produces either a leak or a mangled sentence:
|
||||
///
|
||||
/// - `Authorization: Bearer qqq` — the credential is `qqq`, three lowercase
|
||||
/// letters, indistinguishable in shape from a word. Only the *context* says
|
||||
/// it is a secret, and the context says so unambiguously.
|
||||
/// - `authorization: bearer shares responsibility` — the same context markers,
|
||||
/// lowercase, and the next token is an English verb. Redacting `shares` would
|
||||
/// destroy a sentence and put a false `secret` kind on a receipt.
|
||||
///
|
||||
/// So the arming carries its own strength, and the shape test is applied only
|
||||
/// where the context is weak enough to need it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum CredentialArm {
|
||||
/// Nothing is expected; the next token is judged on its own.
|
||||
None,
|
||||
/// The next token is the credential only if it also *looks* like one.
|
||||
/// Lowercase scheme words and bare `Authorization:` land here.
|
||||
IfShaped,
|
||||
/// The next token is the credential whatever it looks like. Reached by
|
||||
/// canonical HTTP capitalization — `Authorization: Bearer …`, or a bare
|
||||
/// `Bearer` — where the syntax alone settles it.
|
||||
Certain,
|
||||
}
|
||||
|
||||
impl CredentialArm {
|
||||
const fn is_armed(self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one token resolved to, and whether it armed the *next* token.
|
||||
struct TokenOutcome {
|
||||
/// `None` keeps the token verbatim.
|
||||
text: Option<String>,
|
||||
/// Whether — and how strongly — the following token carries the credential
|
||||
/// this one introduced. `Authorization:` and a bare `Bearer` reveal nothing
|
||||
/// themselves; the value after them is the whole secret.
|
||||
arm: CredentialArm,
|
||||
}
|
||||
|
||||
impl TokenOutcome {
|
||||
const fn keep() -> Self {
|
||||
Self {
|
||||
text: None,
|
||||
arm: CredentialArm::None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn keep_and_arm(arm: CredentialArm) -> Self {
|
||||
Self { text: None, arm }
|
||||
}
|
||||
|
||||
fn replace(text: String) -> Self {
|
||||
Self {
|
||||
text: Some(text),
|
||||
arm: CredentialArm::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Redact absolute paths and secret-shaped tokens from `input`.
|
||||
///
|
||||
/// Operates token-by-token over whitespace-free runs, which is enough for the
|
||||
/// bounded, already whitespace-collapsed text this crate transmits and keeps
|
||||
/// the rule set auditable without a regex dependency.
|
||||
///
|
||||
/// One piece of state crosses token boundaries, and it has to: a credential is
|
||||
/// routinely written as *two* tokens (`Authorization: Bearer <token>`), so a
|
||||
/// purely per-token rule either leaks the value or redacts the English word
|
||||
/// `bearer`. Carrying "the next token is the credential" forward — and *how
|
||||
/// certainly*, see [`CredentialArm`] — is what lets this do neither.
|
||||
#[must_use]
|
||||
pub fn redact_for_disclosure(input: &str) -> Redaction {
|
||||
let mut kinds = BTreeSet::new();
|
||||
let tokens: Vec<&str> = input.split(' ').collect();
|
||||
let mut out: Vec<String> = Vec::with_capacity(tokens.len());
|
||||
let mut arm = CredentialArm::None;
|
||||
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
if token.is_empty() {
|
||||
out.push(String::new());
|
||||
continue;
|
||||
}
|
||||
if arm.is_armed() {
|
||||
// `Authorization: Bearer <token>` — the scheme keyword is not the
|
||||
// secret, so it survives and the arming carries past it. A
|
||||
// canonically capitalized keyword also *upgrades* the arming: the
|
||||
// header name alone left the shape in doubt, and `Bearer` settles
|
||||
// it.
|
||||
if is_auth_scheme(token) {
|
||||
if is_canonical_auth_scheme(token) {
|
||||
arm = CredentialArm::Certain;
|
||||
}
|
||||
out.push((*token).to_string());
|
||||
continue;
|
||||
}
|
||||
// Weak arming still defers to shape, so an `authorization:` that
|
||||
// introduces a sentence rather than a secret leaves the sentence
|
||||
// intact — and leaves the receipt honest about having removed
|
||||
// nothing.
|
||||
if arm == CredentialArm::Certain || looks_like_credential_value(token) {
|
||||
kinds.insert(REDACTION_SECRET.to_string());
|
||||
out.push(SECRET_PLACEHOLDER.to_string());
|
||||
arm = CredentialArm::None;
|
||||
continue;
|
||||
}
|
||||
// Fall through: the token is judged on its own merits, and the
|
||||
// arming state is replaced wholesale by `redact_token` below.
|
||||
}
|
||||
|
||||
let next = tokens[index + 1..]
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| !candidate.is_empty());
|
||||
let outcome = redact_token(token, next, &mut kinds);
|
||||
arm = outcome.arm;
|
||||
out.push(outcome.text.unwrap_or_else(|| (*token).to_string()));
|
||||
}
|
||||
|
||||
Redaction {
|
||||
text: out.join(" "),
|
||||
kinds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Redact one whitespace-free token, recording what was removed.
|
||||
///
|
||||
/// `next` is the following non-empty token, used only to decide whether a bare
|
||||
/// authorization scheme keyword is introducing a credential or is just a word.
|
||||
fn redact_token(token: &str, next: Option<&str>, kinds: &mut BTreeSet<String>) -> TokenOutcome {
|
||||
// `NAME=value` / `NAME:value` — a secret-bearing name redacts its value and
|
||||
// keeps the name, which is the useful half.
|
||||
for separator in ['=', ':'] {
|
||||
let Some((name, value)) = token.split_once(separator) else {
|
||||
continue;
|
||||
};
|
||||
let lowered = name.to_ascii_lowercase();
|
||||
let secret_name = SECRET_NAME_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker));
|
||||
if secret_name {
|
||||
// `Authorization:Bearer` carries no secret of its own; the
|
||||
// credential is the next token. Canonical capitalization on the
|
||||
// scheme makes that certain; `authorization:bearer` does not.
|
||||
if is_auth_scheme(value) {
|
||||
return TokenOutcome::keep_and_arm(if is_canonical_auth_scheme(value) {
|
||||
CredentialArm::Certain
|
||||
} else {
|
||||
CredentialArm::IfShaped
|
||||
});
|
||||
}
|
||||
// A bare `Authorization:` only introduces a credential when one
|
||||
// actually follows. `authorization: needed before merge` is a
|
||||
// sentence, and redacting `needed` would report a secret that was
|
||||
// never there. The arming stays weak here even when a scheme word
|
||||
// follows — the scheme token itself decides, on the next pass,
|
||||
// whether its capitalization upgrades it.
|
||||
if value.is_empty() {
|
||||
return if next
|
||||
.is_some_and(|next| is_auth_scheme(next) || looks_like_credential_value(next))
|
||||
{
|
||||
TokenOutcome::keep_and_arm(CredentialArm::IfShaped)
|
||||
} else {
|
||||
TokenOutcome::keep()
|
||||
};
|
||||
}
|
||||
kinds.insert(REDACTION_SECRET.to_string());
|
||||
return TokenOutcome::replace(format!("{name}{separator}{SECRET_PLACEHOLDER}"));
|
||||
}
|
||||
// A path assigned to a variable is still a path.
|
||||
if let Some(kind) = classify_path(value) {
|
||||
kinds.insert(kind.to_string());
|
||||
return TokenOutcome::replace(format!("{name}{separator}{PATH_PLACEHOLDER}"));
|
||||
}
|
||||
}
|
||||
|
||||
// A bare `Bearer` in canonical HTTP capitalization introduces a credential
|
||||
// on its own — that is what makes `Bearer qqq` lose `qqq`, which no shape
|
||||
// test could ever do. Any other scheme keyword, and any other
|
||||
// capitalization, needs something credential-shaped to actually follow:
|
||||
// `bearer of bad news` and `Token holders vote` are prose.
|
||||
if unwrap_token(token) == SELF_EVIDENT_AUTH_SCHEME {
|
||||
return TokenOutcome::keep_and_arm(CredentialArm::Certain);
|
||||
}
|
||||
if is_auth_scheme(token) && next.is_some_and(looks_like_credential_value) {
|
||||
return TokenOutcome::keep_and_arm(CredentialArm::IfShaped);
|
||||
}
|
||||
|
||||
let lowered = token.to_ascii_lowercase();
|
||||
if SECRET_VALUE_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| lowered.starts_with(prefix))
|
||||
|| looks_like_aws_access_key(token)
|
||||
{
|
||||
kinds.insert(REDACTION_SECRET.to_string());
|
||||
return TokenOutcome::replace(SECRET_PLACEHOLDER.to_string());
|
||||
}
|
||||
|
||||
if let Some(kind) = classify_path(token) {
|
||||
kinds.insert(kind.to_string());
|
||||
return TokenOutcome::replace(PATH_PLACEHOLDER.to_string());
|
||||
}
|
||||
|
||||
TokenOutcome::keep()
|
||||
}
|
||||
|
||||
/// Strip the punctuation a token picks up from surrounding prose.
|
||||
fn unwrap_token(token: &str) -> &str {
|
||||
token
|
||||
.trim_start_matches(['(', '[', '"', '\'', '<'])
|
||||
.trim_end_matches([',', '.', ';', ':', ')', ']', '"', '\'', '>', '!', '?'])
|
||||
}
|
||||
|
||||
/// Whether a token is an HTTP authorization scheme keyword (and nothing else),
|
||||
/// in any capitalization.
|
||||
fn is_auth_scheme(token: &str) -> bool {
|
||||
let word = unwrap_token(token);
|
||||
!word.is_empty()
|
||||
&& word.chars().all(|ch| ch.is_ascii_alphabetic())
|
||||
&& AUTH_SCHEMES
|
||||
.iter()
|
||||
.any(|scheme| scheme.eq_ignore_ascii_case(word))
|
||||
}
|
||||
|
||||
/// Whether a token is a scheme keyword spelled the way HTTP spells it —
|
||||
/// `Bearer`, not `bearer` or `BEARER`.
|
||||
///
|
||||
/// This is the evidence that separates syntax from prose. It is a weak signal
|
||||
/// read honestly: capitalization is *suggestive*, so it upgrades an arming that
|
||||
/// header context already established, and stands alone only for
|
||||
/// [`SELF_EVIDENT_AUTH_SCHEME`].
|
||||
fn is_canonical_auth_scheme(token: &str) -> bool {
|
||||
AUTH_SCHEMES.contains(&unwrap_token(token))
|
||||
}
|
||||
|
||||
/// Whether a token has the shape of an opaque credential value.
|
||||
///
|
||||
/// Deliberately conservative: long, punctuation-free-ish, and mixing letters
|
||||
/// with digits. Ordinary words — however long — never qualify, which is what
|
||||
/// keeps `bearer of responsibility` out of the redactor.
|
||||
fn looks_like_credential_value(token: &str) -> bool {
|
||||
let value = unwrap_token(token);
|
||||
if value.chars().count() < CREDENTIAL_VALUE_MIN_LEN {
|
||||
return false;
|
||||
}
|
||||
let mut has_digit = false;
|
||||
let mut has_alpha = false;
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
has_digit = true;
|
||||
} else if ch.is_ascii_alphabetic() {
|
||||
has_alpha = true;
|
||||
} else if !matches!(ch, '-' | '_' | '.' | '=' | '+' | '/' | '~') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
has_digit && has_alpha
|
||||
}
|
||||
|
||||
/// Whether a token is an AWS access key id.
|
||||
///
|
||||
/// Requires the exact uppercase prefix *and* the full length, so `Asia` and
|
||||
/// `ASIA` (the continent, in prose or in a shouted heading) are left alone
|
||||
/// while `ASIA` + 16 key characters is removed.
|
||||
fn looks_like_aws_access_key(token: &str) -> bool {
|
||||
let value = unwrap_token(token);
|
||||
if value.len() < AWS_KEY_ID_LEN {
|
||||
return false;
|
||||
}
|
||||
if !AWS_KEY_ID_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| value.starts_with(prefix))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Whether a token is an absolute or home-relative filesystem path.
|
||||
///
|
||||
/// A lone `/` or `~` is punctuation, not a path; a Windows drive letter needs
|
||||
/// its `:\` to count.
|
||||
fn looks_absolute(token: &str) -> bool {
|
||||
let trimmed = token.trim_start_matches(['(', '[', '"', '\'']);
|
||||
if trimmed.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix('/') {
|
||||
return rest.starts_with(|ch: char| ch.is_ascii_alphanumeric() || ch == '.' || ch == '_');
|
||||
}
|
||||
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
|
||||
return true;
|
||||
}
|
||||
if trimmed.starts_with("\\\\") {
|
||||
return true;
|
||||
}
|
||||
let mut chars = trimmed.chars();
|
||||
matches!(
|
||||
(chars.next(), chars.next(), chars.next()),
|
||||
(Some(drive), Some(':'), Some('\\' | '/')) if drive.is_ascii_alphabetic()
|
||||
)
|
||||
}
|
||||
|
||||
/// Which path kind a token is, if any — the single decision both the bare-token
|
||||
/// and the `NAME=value` rules ask, so an assignment can never be classified
|
||||
/// differently from the same value standing alone.
|
||||
///
|
||||
/// Escaped spellings are resolved first: a path that arrives inside a JSON
|
||||
/// string is written `crates\/tui\/src\/main.rs` or `crates\\tui\\src\\main.rs`,
|
||||
/// and reading only the literal characters would let either spelling through
|
||||
/// while the receipt claimed nothing was removed.
|
||||
///
|
||||
/// A URL is not a filesystem path and is left to the URL-bearing-input guard
|
||||
/// that already refuses such a task, so a token carrying a scheme is declined
|
||||
/// here rather than silently reclassified.
|
||||
fn classify_path(token: &str) -> Option<&'static str> {
|
||||
let raw = trim_path_punctuation(token);
|
||||
let unescaped = unescape_path(raw);
|
||||
let candidate = trim_path_punctuation(&unescaped);
|
||||
if candidate.contains("://") {
|
||||
return None;
|
||||
}
|
||||
// Both spellings are asked, because unescaping is lossy in one direction
|
||||
// that matters: a UNC share is *literally* `\\host\share`, and collapsing
|
||||
// its leading pair would demote a machine-identifying path to a relative
|
||||
// one.
|
||||
if looks_absolute(raw) || looks_absolute(candidate) {
|
||||
return Some(REDACTION_ABSOLUTE_PATH);
|
||||
}
|
||||
if looks_relative(candidate) {
|
||||
return Some(REDACTION_RELATIVE_PATH);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip the punctuation a path picks up from surrounding prose, including the
|
||||
/// escaped quotes it picks up from a JSON string.
|
||||
fn trim_path_punctuation(token: &str) -> &str {
|
||||
token
|
||||
.trim_start_matches(['(', '[', '{', '"', '\'', '<', '`'])
|
||||
.trim_end_matches([
|
||||
',', ';', ':', '.', ')', ']', '}', '"', '\'', '>', '`', '!', '?',
|
||||
])
|
||||
}
|
||||
|
||||
/// Resolve `\/` and `\\` to the separator they escape, and drop escaped quotes.
|
||||
///
|
||||
/// Returns an owned string only because most tokens need no work; the borrowed
|
||||
/// fast path is not worth a second code path in a function this small.
|
||||
fn unescape_path(token: &str) -> String {
|
||||
let mut out = String::with_capacity(token.len());
|
||||
let mut chars = token.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.peek() {
|
||||
Some('/') => {
|
||||
out.push('/');
|
||||
chars.next();
|
||||
}
|
||||
Some('\\') => {
|
||||
out.push('\\');
|
||||
chars.next();
|
||||
}
|
||||
Some('"') | Some('\'') => {
|
||||
chars.next();
|
||||
}
|
||||
// A lone backslash is a separator in its own right (`C:\Users`).
|
||||
_ => out.push('\\'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a token is a repo-relative filesystem path, judged conservatively.
|
||||
///
|
||||
/// Two signals, and nothing else, because the cost of a false positive here is
|
||||
/// paid in the operator's own summary: a redacted word plus a `relative_path`
|
||||
/// kind on a receipt that removed prose.
|
||||
///
|
||||
/// 1. **Explicit relative syntax** — `./x`, `../x`, and their backslash forms.
|
||||
/// Nothing but a path is spelled that way.
|
||||
/// 2. **A file extension on the last segment** of a multi-segment token: stem
|
||||
/// plus 1–8 ASCII *alphabetic* characters. The alphabetic requirement is
|
||||
/// what keeps `zai/glm-5.2` a model label rather than a file, and the
|
||||
/// multi-segment requirement is what keeps every bare `provider/model` pair
|
||||
/// — `deepseek/deepseek-v4-flash`, `anthropic/claude-opus-5` — intact.
|
||||
///
|
||||
/// The deliberate gap is an extension-less directory (`crates/tui/src`), which
|
||||
/// stays. Catching it would need a rule that cannot tell a directory from
|
||||
/// `read/write/execute`, and shredding prose to hide a directory name is the
|
||||
/// worse trade.
|
||||
fn looks_relative(candidate: &str) -> bool {
|
||||
if !candidate.contains(['/', '\\']) {
|
||||
return false;
|
||||
}
|
||||
let explicit_prefix = ["./", "../", ".\\", "..\\"]
|
||||
.iter()
|
||||
.any(|prefix| candidate.starts_with(prefix));
|
||||
if explicit_prefix {
|
||||
return true;
|
||||
}
|
||||
let trimmed = candidate.trim_end_matches(['/', '\\']);
|
||||
let segments: Vec<&str> = trimmed.split(['/', '\\']).collect();
|
||||
if segments.len() < 2 || segments.iter().any(|segment| segment.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
let last = segments[segments.len() - 1];
|
||||
let Some((stem, extension)) = last.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
!stem.is_empty()
|
||||
&& (1..=8).contains(&extension.chars().count())
|
||||
&& extension.chars().all(|ch| ch.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Whether a string contains anything this module would redact.
|
||||
///
|
||||
/// Used by assertions and by durable-write guards that must fail loudly rather
|
||||
/// than persist a path or a key.
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub fn contains_redactable(input: &str) -> bool {
|
||||
redact_for_disclosure(input).redacted()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absolute_paths_are_replaced_and_recorded() {
|
||||
let redaction = redact_for_disclosure("fix /Users/hunter/src/app/main.rs and ~/notes.md");
|
||||
|
||||
assert!(!redaction.text().contains("/Users/"));
|
||||
assert!(!redaction.text().contains("~/"));
|
||||
assert!(redaction.text().contains(PATH_PLACEHOLDER));
|
||||
assert!(redaction.redacted());
|
||||
assert_eq!(redaction.kinds(), vec![REDACTION_ABSOLUTE_PATH.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_paths_and_unc_shares_count_as_absolute() {
|
||||
for token in ["C:\\Users\\hunter\\app", "\\\\share\\team\\notes"] {
|
||||
let redaction = redact_for_disclosure(token);
|
||||
assert!(redaction.redacted(), "{token} must be redacted");
|
||||
assert_eq!(redaction.text(), PATH_PLACEHOLDER);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_shaped_tokens_and_assignments_are_replaced() {
|
||||
let redaction = redact_for_disclosure("use sk-live-abc123 and ZAI_API_KEY=zzz");
|
||||
|
||||
assert!(!redaction.text().contains("sk-live-abc123"));
|
||||
assert!(!redaction.text().contains("zzz"));
|
||||
assert!(
|
||||
redaction.text().contains("ZAI_API_KEY=<redacted>"),
|
||||
"the name stays, the value goes: {}",
|
||||
redaction.text()
|
||||
);
|
||||
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
|
||||
}
|
||||
|
||||
/// The credential in an `Authorization` header is a *separate token* from
|
||||
/// the header name and from the scheme keyword. Redacting only the keyword
|
||||
/// leaves the secret in the clear while the receipt claims a secret was
|
||||
/// removed — the exact failure this covers.
|
||||
#[test]
|
||||
fn a_multi_token_authorization_header_loses_its_credential() {
|
||||
for header in [
|
||||
"Authorization: Bearer sk-live-abc123def456",
|
||||
"authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"-H Authorization:Bearer abcdef0123456789abcdef",
|
||||
] {
|
||||
let redaction = redact_for_disclosure(header);
|
||||
let text = redaction.text();
|
||||
assert!(redaction.redacted(), "{header} must be redacted");
|
||||
assert!(
|
||||
text.contains(SECRET_PLACEHOLDER),
|
||||
"{header} must carry a placeholder: {text}"
|
||||
);
|
||||
for leaked in [
|
||||
"sk-live-abc123def456",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
"abcdef0123456789abcdef",
|
||||
] {
|
||||
assert!(!text.contains(leaked), "{leaked} leaked through: {text}");
|
||||
}
|
||||
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bare scheme keyword introduces a credential only when a
|
||||
/// credential-shaped token actually follows it.
|
||||
#[test]
|
||||
fn a_bare_bearer_token_is_removed_but_the_scheme_word_survives() {
|
||||
let redaction = redact_for_disclosure("send Bearer 9f8e7d6c5b4a3f2e1d0c9b8a and retry");
|
||||
let text = redaction.text();
|
||||
|
||||
assert!(
|
||||
text.contains("Bearer"),
|
||||
"the scheme keyword is not a secret"
|
||||
);
|
||||
assert!(!text.contains("9f8e7d6c5b4a3f2e1d0c9b8a"), "{text}");
|
||||
assert!(text.ends_with("and retry"), "{text}");
|
||||
}
|
||||
|
||||
/// Ordinary words that merely *look* like credential prefixes must survive,
|
||||
/// and must not report a `secret` redaction kind. `Asia`, `bearer`, and any
|
||||
/// identifier containing them are prose, not keys.
|
||||
#[test]
|
||||
fn ordinary_words_and_identifiers_are_not_mistaken_for_secrets() {
|
||||
for text in [
|
||||
"ship the Asia region rollout",
|
||||
"ASIA is a continent, not a key",
|
||||
"the bearer of this note may enter",
|
||||
"authorization: needed before merge",
|
||||
"rename bearer_token_header to auth_header_name",
|
||||
"aws_region defaults to us-east-1",
|
||||
"pk_display is a public identifier",
|
||||
] {
|
||||
let redaction = redact_for_disclosure(text);
|
||||
assert!(!redaction.redacted(), "{text} must survive: {redaction:?}");
|
||||
assert_eq!(redaction.text(), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// The adversarial prose set. Every line here is ordinary English that the
|
||||
/// scheme/prefix rules could plausibly mistake for credential syntax, and
|
||||
/// every one of them must come back byte-identical with an empty `kinds`.
|
||||
///
|
||||
/// A false positive is not a harmless over-redaction: it mangles the routing
|
||||
/// summary a human reads *and* writes `secret` onto a durable receipt that
|
||||
/// removed nothing, which makes the disclosure a lie in the safe direction.
|
||||
#[test]
|
||||
fn adversarial_prose_survives_the_credential_state_machine() {
|
||||
for text in [
|
||||
// The lowercase scheme word, in every position that could arm it.
|
||||
"bearer shares responsibility for the rollout",
|
||||
"the bearer of bad news is rarely thanked",
|
||||
"bearer",
|
||||
"each bearer token header is rewritten downstream",
|
||||
// Capitalized scheme words that are ordinary English. These are why
|
||||
// canonical capitalization arms only for `Bearer`.
|
||||
"Token holders vote on the proposal",
|
||||
"Basic auth is enabled for the staging endpoint",
|
||||
"Digest the results before the review",
|
||||
// Weak header context introducing a sentence, not a secret.
|
||||
"authorization: needed before merge",
|
||||
"authorization: bearer shares responsibility",
|
||||
// Identifiers and prefixes that resemble key material.
|
||||
"variables like aws_region and pk_display stay readable",
|
||||
"aws_ prefixed variables are documented in the runbook",
|
||||
// `pk_` is a *public* key prefix and carries nothing; it is
|
||||
// deliberately absent from SECRET_VALUE_PREFIXES. (`sk_` is not
|
||||
// listed here because it genuinely is a secret prefix and redacting
|
||||
// it is correct.)
|
||||
"pk_ and pub_ are conventions, not values",
|
||||
"asia and akia are four letter strings",
|
||||
"the variables were renamed in the same commit",
|
||||
// Long lowercase words are still words: shape alone must not fire.
|
||||
"internationalization is spelled with eighteen letters",
|
||||
] {
|
||||
let redaction = redact_for_disclosure(text);
|
||||
assert!(
|
||||
!redaction.redacted(),
|
||||
"{text:?} is prose and must survive untouched: {redaction:?}"
|
||||
);
|
||||
assert_eq!(redaction.text(), text);
|
||||
assert!(
|
||||
redaction.kinds().is_empty(),
|
||||
"{text:?} must not claim a redaction it did not make"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The adversarial credential set. A real credential is often short,
|
||||
/// lowercase, punctuated, or otherwise shapeless — `Bearer qqq` is the
|
||||
/// canonical example, and no shape test could ever catch it. Context has to.
|
||||
#[test]
|
||||
fn adversarial_credentials_lose_the_whole_value() {
|
||||
for (text, leaked) in [
|
||||
// The short, shapeless credential. This is the leak the evidence
|
||||
// model exists to close.
|
||||
("Bearer qqq", "qqq"),
|
||||
("Authorization: Bearer qqq", "qqq"),
|
||||
("authorization: Bearer qqq", "qqq"),
|
||||
// Punctuated and quoted header forms.
|
||||
("Authorization: Bearer qqq.", "qqq"),
|
||||
("-H \"Authorization: Bearer qqq\"", "qqq"),
|
||||
("Authorization:Bearer qqq", "qqq"),
|
||||
// The scheme keyword may not absorb the redaction and leave the
|
||||
// value behind.
|
||||
("send Bearer hunter2 now", "hunter2"),
|
||||
(
|
||||
"curl -H Authorization: Bearer sk-live-0000 -X POST",
|
||||
"sk-live-0000",
|
||||
),
|
||||
] {
|
||||
let redaction = redact_for_disclosure(text);
|
||||
let redacted_text = redaction.text();
|
||||
assert!(
|
||||
redaction.redacted(),
|
||||
"{text:?} carries a credential and must be redacted"
|
||||
);
|
||||
assert!(
|
||||
!redacted_text.split(' ').any(|token| token == leaked
|
||||
|| token.trim_end_matches(['.', ',', '"', '\'']) == leaked),
|
||||
"{leaked:?} leaked through {text:?}: {redacted_text}"
|
||||
);
|
||||
assert!(
|
||||
redacted_text.contains(SECRET_PLACEHOLDER),
|
||||
"{text:?} must carry a placeholder: {redacted_text}"
|
||||
);
|
||||
assert!(
|
||||
redaction.kinds().contains(&REDACTION_SECRET.to_string()),
|
||||
"{text:?} must disclose the secret kind"
|
||||
);
|
||||
// The scheme keyword is not the secret and must still be readable,
|
||||
// so a reader can tell *what* was removed.
|
||||
assert!(
|
||||
redacted_text.to_ascii_lowercase().contains("bearer"),
|
||||
"the scheme keyword must survive: {redacted_text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The one documented false positive of the capitalization rule, pinned so
|
||||
/// it stays deliberate rather than becoming a surprise. Capitalized `Bearer`
|
||||
/// followed by a word is treated as header syntax; ordinary prose spells it
|
||||
/// lowercase, which the test above covers.
|
||||
#[test]
|
||||
fn capitalized_bearer_arms_even_in_prose_and_that_is_the_known_cost() {
|
||||
let redaction = redact_for_disclosure("Bearer tokens are rotated weekly");
|
||||
assert_eq!(redaction.text(), "Bearer <redacted> are rotated weekly");
|
||||
|
||||
// The lowercase spelling — what prose actually uses — is untouched.
|
||||
let prose = redact_for_disclosure("bearer tokens are rotated weekly");
|
||||
assert!(!prose.redacted());
|
||||
}
|
||||
|
||||
/// The real AWS shape still goes, so dropping the bare `asia`/`akia`
|
||||
/// prefixes did not trade a false positive for a false negative.
|
||||
#[test]
|
||||
fn full_aws_access_key_ids_are_still_removed() {
|
||||
for key in ["AKIAIOSFODNN7EXAMPLE", "ASIAIOSFODNN7EXAMPLE"] {
|
||||
let redaction = redact_for_disclosure(&format!("creds {key} rotated"));
|
||||
assert!(!redaction.text().contains(key), "{}", redaction.text());
|
||||
assert_eq!(redaction.kinds(), vec![REDACTION_SECRET.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_prose_is_left_alone() {
|
||||
let redaction = redact_for_disclosure("refactor the parser and add a regression test");
|
||||
assert!(!redaction.redacted());
|
||||
assert_eq!(
|
||||
redaction.text(),
|
||||
"refactor the parser and add a regression test"
|
||||
);
|
||||
assert!(redaction.kinds().is_empty());
|
||||
}
|
||||
|
||||
/// A repo-relative path discloses the private tree's shape to whatever
|
||||
/// provider the routing summary reaches, and is persisted next to it. Every
|
||||
/// spelling one arrives in — bare, quoted, JSON-escaped with `\/` or `\\`,
|
||||
/// explicitly relative, assigned to a name, trailing prose punctuation —
|
||||
/// must lose the path *and* say so on the receipt.
|
||||
#[test]
|
||||
fn repo_relative_paths_are_redacted_in_every_spelling_and_disclosed() {
|
||||
for token in [
|
||||
"crates/tui/src/main.rs",
|
||||
"src/lib.rs",
|
||||
"web/lib/deploy-preflight.test.ts",
|
||||
".github/workflows/web.yml",
|
||||
"crates\\tui\\src\\main.rs",
|
||||
"crates\\/tui\\/src\\/main.rs",
|
||||
"\\\"crates/tui/src/main.rs\\\"",
|
||||
"\"crates/tui/src/main.rs\"",
|
||||
"(crates/tui/src/main.rs)",
|
||||
"./deploy.sh",
|
||||
"../../secret/notes.md",
|
||||
"..\\secret\\notes.md",
|
||||
] {
|
||||
let redaction = redact_for_disclosure(token);
|
||||
assert!(redaction.redacted(), "{token} must be redacted");
|
||||
assert!(
|
||||
!redaction.text().contains("main.rs")
|
||||
&& !redaction.text().contains("notes.md")
|
||||
&& !redaction.text().contains("deploy"),
|
||||
"{token} leaked: {}",
|
||||
redaction.text()
|
||||
);
|
||||
assert!(
|
||||
redaction
|
||||
.kinds()
|
||||
.contains(&REDACTION_RELATIVE_PATH.to_string()),
|
||||
"{token} must disclose the relative_path kind: {:?}",
|
||||
redaction.kinds()
|
||||
);
|
||||
}
|
||||
|
||||
// In a sentence, and as a value: the name survives, the path does not.
|
||||
let sentence = redact_for_disclosure("patch crates/tui/src/main.rs, then path=src/lib.rs");
|
||||
assert_eq!(
|
||||
sentence.text(),
|
||||
"patch <path> then path=<path>",
|
||||
"prose keeps its shape around the placeholder"
|
||||
);
|
||||
assert_eq!(
|
||||
sentence.kinds(),
|
||||
vec![REDACTION_RELATIVE_PATH.to_string()],
|
||||
"one kind, honestly reported"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other half of the same rule: it must not shred ordinary prose,
|
||||
/// `provider/model` labels, or bare punctuation, because a false positive
|
||||
/// here costs the operator their own summary *and* puts a redaction kind on
|
||||
/// a receipt that removed nothing.
|
||||
#[test]
|
||||
fn prose_labels_and_bare_punctuation_are_not_paths() {
|
||||
for token in [
|
||||
// Provider/model labels — the exact shape a Fleet receipt carries.
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"zai/glm-5.2",
|
||||
"anthropic/claude-opus-5",
|
||||
"workspace/glm-pair",
|
||||
// Prose that happens to carry separators.
|
||||
"a/b",
|
||||
"and/or",
|
||||
"read/write/execute",
|
||||
"TODO/FIXME",
|
||||
"provider/model/reasoning",
|
||||
// Bare punctuation and non-paths.
|
||||
"/",
|
||||
"~",
|
||||
"5:30",
|
||||
"v0.9.2",
|
||||
// A URL is not a filesystem path; the URL-bearing-input guard owns
|
||||
// it, and reclassifying it here would be a silent behavior change.
|
||||
"https://example.test/a/b.rs",
|
||||
] {
|
||||
let redaction = redact_for_disclosure(token);
|
||||
assert!(!redaction.redacted(), "{token} must not be redacted");
|
||||
assert_eq!(redaction.text(), token, "{token} must survive verbatim");
|
||||
}
|
||||
}
|
||||
|
||||
/// The documented gap, pinned so it stays a decision rather than a
|
||||
/// surprise: an extension-less directory survives, because no rule can
|
||||
/// separate it from `read/write/execute` without shredding prose.
|
||||
#[test]
|
||||
fn an_extension_less_directory_is_the_known_residual() {
|
||||
let redaction = redact_for_disclosure("look in crates/tui/src");
|
||||
assert!(!redaction.redacted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_redactable_matches_the_redactor() {
|
||||
assert!(contains_redactable("/Users/hunter"));
|
||||
assert!(contains_redactable("token=abc"));
|
||||
assert!(!contains_redactable("land a fix in the workflow crate"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
//! End-to-end vertical for an exact named Fleet at the crate boundary:
|
||||
//! parse → attach a reusable Reasoning Router → freeze routes → router decision
|
||||
//! → reasoning resolution → immutable Workflow snapshot.
|
||||
//!
|
||||
//! No live provider calls: the router's response is a fixture string.
|
||||
|
||||
use codewhale_workflow::{
|
||||
CapturedReasoningRouter, CredentialReadiness, EffectiveReasoning, EffectiveReasoningSource,
|
||||
EndpointIdentity, FleetDocument, FleetSearchRoot, FleetSnapshot, NamedFleetError,
|
||||
PermissionCeiling, PreflightedRoute, ProviderEffectiveReasoning, QualifiedFleetId,
|
||||
REASONING_ROUTER_DIR, REASONING_ROUTER_SERVICE_KIND, ReasoningCapability, ReasoningRouterError,
|
||||
ReasoningRouterProfile, ReasoningTier, RequestedReasoning, RouterAvailability,
|
||||
RouterCallReasoning, RouterIdentity, ShellCeiling, bounded_routing_payload,
|
||||
parse_router_decision, resolve_exact_member_reasoning, router_call_plan, router_system_prompt,
|
||||
router_user_message,
|
||||
};
|
||||
|
||||
const GLM_FLEET: &str = r#"
|
||||
name = "glm-pair"
|
||||
description = "GLM workers with a shared GPT-5.6 Luna reasoning router"
|
||||
schema = "exact"
|
||||
schema_revision = 1
|
||||
reasoning_router = "luna-low"
|
||||
|
||||
[[members]]
|
||||
id = "implementer"
|
||||
role = "builder"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "auto"
|
||||
permissions = "read_write"
|
||||
|
||||
[[members]]
|
||||
id = "auditor"
|
||||
role = "reviewer"
|
||||
provider = "zai"
|
||||
model = "glm-5"
|
||||
reasoning = "high"
|
||||
permissions = "read_only"
|
||||
"#;
|
||||
|
||||
/// The user's example: GPT-5.6 Luna, called at `low`.
|
||||
const LUNA: &str = r#"
|
||||
name = "luna-low"
|
||||
schema = "reasoning_router"
|
||||
schema_revision = 1
|
||||
provider = "openai"
|
||||
model = "gpt-5.6-luna"
|
||||
call_reasoning = "low"
|
||||
"#;
|
||||
|
||||
fn fleet_id(name: &str) -> QualifiedFleetId {
|
||||
QualifiedFleetId {
|
||||
name: name.to_string(),
|
||||
origin: "workspace".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn luna() -> CapturedReasoningRouter {
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("router profile");
|
||||
CapturedReasoningRouter::from_profile(&profile, "workspace")
|
||||
}
|
||||
|
||||
fn route(member: &str, provider: &str, model: &str) -> PreflightedRoute {
|
||||
PreflightedRoute {
|
||||
member_id: member.to_string(),
|
||||
provider_id: provider.to_string(),
|
||||
provider_kind: provider.to_string(),
|
||||
declared_model: model.to_string(),
|
||||
wire_model: model.to_string(),
|
||||
endpoint: EndpointIdentity::from_base_url("https://api.example.test/v1"),
|
||||
credential: CredentialReadiness::Configured,
|
||||
capability: ReasoningCapability::tiered(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A workspace holding one Fleet and one Router profile, plus its search roots.
|
||||
fn workspace_with(fleet: &str, router: Option<&str>) -> (tempfile::TempDir, Vec<FleetSearchRoot>) {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
std::fs::create_dir_all(tmp.path().join("fleets")).expect("fleets dir");
|
||||
std::fs::write(tmp.path().join("fleets/glm-pair.toml"), fleet).expect("fleet");
|
||||
if let Some(router) = router {
|
||||
std::fs::create_dir_all(tmp.path().join(REASONING_ROUTER_DIR)).expect("routers dir");
|
||||
std::fs::write(
|
||||
tmp.path().join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
router,
|
||||
)
|
||||
.expect("router");
|
||||
}
|
||||
let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
|
||||
(tmp, roots)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fleet_and_its_referenced_router_resolve_reasoning_without_moving_the_route() {
|
||||
let (_tmp, roots) = workspace_with(GLM_FLEET, Some(LUNA));
|
||||
let (document, id) = FleetDocument::load_by_name("glm-pair", &roots).expect("fleet loads");
|
||||
let exact = document.exact().expect("exact");
|
||||
|
||||
// The Router is a *reference* to a separately saved service.
|
||||
assert_eq!(exact.reasoning_router.as_deref(), Some("luna-low"));
|
||||
assert!(exact.legacy_inline_router().is_none());
|
||||
|
||||
let (profile, router_id) =
|
||||
ReasoningRouterProfile::load_by_name("luna-low", &roots).expect("router loads");
|
||||
assert_eq!(router_id.qualified(), "workspace/luna-low");
|
||||
let captured = CapturedReasoningRouter::from_profile(&profile, router_id.origin);
|
||||
|
||||
let snapshot = FleetSnapshot::capture(id, &document, "2026-07-26T00:00:00Z", Some(captured))
|
||||
.expect("capture");
|
||||
|
||||
// Routes are frozen from the snapshot, before any reasoning resolution.
|
||||
let member = snapshot.member("implementer").expect("member");
|
||||
let frozen = member.route.clone();
|
||||
assert_eq!(frozen.provider, "zai");
|
||||
assert_eq!(frozen.model, "glm-5");
|
||||
|
||||
// The Router is a non-dispatchable service with no authority.
|
||||
let router = snapshot.router().expect("router service");
|
||||
assert_eq!(router.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert_eq!(router.route.model, "gpt-5.6-luna");
|
||||
assert_eq!(router.requested_call_reasoning, RouterCallReasoning::Low);
|
||||
assert!(!router.dispatchable);
|
||||
assert!(!router.permissions.tools);
|
||||
|
||||
let decision = parse_router_decision(
|
||||
r#"```json
|
||||
{"reasoning":"max"}
|
||||
```"#,
|
||||
)
|
||||
.expect("router decision parses");
|
||||
|
||||
let identity = RouterIdentity::from_captured(
|
||||
router,
|
||||
Some(&route("router", "openai", "gpt-5.6-luna")),
|
||||
Some(
|
||||
router_call_plan(
|
||||
router.requested_call_reasoning,
|
||||
&ReasoningCapability::tiered(),
|
||||
)
|
||||
.disclosure,
|
||||
),
|
||||
);
|
||||
|
||||
let resolved = resolve_exact_member_reasoning(
|
||||
&member.id,
|
||||
&frozen,
|
||||
member.requested_reasoning,
|
||||
&ReasoningCapability::tiered(),
|
||||
&RouterAvailability::Ready,
|
||||
Some(&decision),
|
||||
Some(&identity),
|
||||
)
|
||||
.expect("ready router resolves auto");
|
||||
|
||||
assert_eq!(resolved.requested(), RequestedReasoning::Auto);
|
||||
assert_eq!(
|
||||
resolved.effective(),
|
||||
EffectiveReasoning::Tier(ReasoningTier::Max)
|
||||
);
|
||||
assert_eq!(resolved.source(), EffectiveReasoningSource::FleetRouter);
|
||||
|
||||
// The router's own call ran at the configured `low`, and says so.
|
||||
let call = resolved
|
||||
.router()
|
||||
.expect("router identity")
|
||||
.call
|
||||
.as_ref()
|
||||
.expect("call disclosure");
|
||||
assert_eq!(call.requested, "low");
|
||||
assert_eq!(call.effective, "low");
|
||||
|
||||
// The worker's provider/model did not move.
|
||||
assert_eq!(snapshot.member("implementer").unwrap().route, frozen);
|
||||
}
|
||||
|
||||
/// One saved Router profile, referenced by two different Fleets.
|
||||
#[test]
|
||||
fn one_router_profile_serves_two_fleets() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
std::fs::create_dir_all(tmp.path().join("fleets")).expect("fleets dir");
|
||||
std::fs::create_dir_all(tmp.path().join(REASONING_ROUTER_DIR)).expect("routers dir");
|
||||
std::fs::write(
|
||||
tmp.path().join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA,
|
||||
)
|
||||
.expect("router");
|
||||
std::fs::write(tmp.path().join("fleets/glm-pair.toml"), GLM_FLEET).expect("first");
|
||||
std::fs::write(
|
||||
tmp.path().join("fleets/glm-solo.toml"),
|
||||
GLM_FLEET.replace("name = \"glm-pair\"", "name = \"glm-solo\""),
|
||||
)
|
||||
.expect("second");
|
||||
let roots = vec![FleetSearchRoot::new("workspace", tmp.path())];
|
||||
|
||||
let mut snapshots = Vec::new();
|
||||
for name in ["glm-pair", "glm-solo"] {
|
||||
let (document, id) = FleetDocument::load_by_name(name, &roots).expect("fleet loads");
|
||||
let reference = document
|
||||
.exact()
|
||||
.expect("exact")
|
||||
.reasoning_router
|
||||
.clone()
|
||||
.expect("router reference");
|
||||
let (profile, router_id) =
|
||||
ReasoningRouterProfile::load_by_name(&reference, &roots).expect("router loads");
|
||||
snapshots.push(
|
||||
FleetSnapshot::capture(
|
||||
id,
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
Some(CapturedReasoningRouter::from_profile(
|
||||
&profile,
|
||||
router_id.origin,
|
||||
)),
|
||||
)
|
||||
.expect("capture"),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
snapshots[0].router(),
|
||||
snapshots[1].router(),
|
||||
"both fleets attach the identical captured router service"
|
||||
);
|
||||
assert_ne!(snapshots[0].fleet(), snapshots[1].fleet());
|
||||
assert_eq!(
|
||||
snapshots[0].router().expect("router").qualified(),
|
||||
"workspace/luna-low"
|
||||
);
|
||||
}
|
||||
|
||||
/// A bare Router name defined in two origins is ambiguous; a qualified origin
|
||||
/// resolves it. Shadowing would silently change which provider sees every
|
||||
/// routing summary.
|
||||
#[test]
|
||||
fn a_router_defined_in_two_origins_is_ambiguous_until_qualified() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let home = tmp.path().join("home");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
for root in [&home, &workspace] {
|
||||
std::fs::create_dir_all(root.join(REASONING_ROUTER_DIR)).expect("routers dir");
|
||||
}
|
||||
std::fs::write(
|
||||
home.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA.replace("gpt-5.6-luna", "gpt-5.6-luna-mini"),
|
||||
)
|
||||
.expect("home");
|
||||
std::fs::write(
|
||||
workspace.join(REASONING_ROUTER_DIR).join("luna-low.toml"),
|
||||
LUNA,
|
||||
)
|
||||
.expect("workspace");
|
||||
|
||||
let roots = vec![
|
||||
FleetSearchRoot::new("codewhale_home", &home),
|
||||
FleetSearchRoot::new("workspace", &workspace),
|
||||
];
|
||||
|
||||
let err = ReasoningRouterProfile::load_by_name("luna-low", &roots)
|
||||
.expect_err("a bare name must not be resolved by shadowing");
|
||||
assert!(
|
||||
matches!(err, ReasoningRouterError::AmbiguousRouter { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ReasoningRouterProfile::load_by_name("workspace/luna-low", &roots)
|
||||
.expect("qualified")
|
||||
.0
|
||||
.model,
|
||||
"gpt-5.6-luna"
|
||||
);
|
||||
assert_eq!(
|
||||
ReasoningRouterProfile::load_by_name("codewhale_home/luna-low", &roots)
|
||||
.expect("qualified")
|
||||
.0
|
||||
.model,
|
||||
"gpt-5.6-luna-mini"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Router may only run at `off` or `low`. `medium`/`high`/`max` are rejected,
|
||||
/// not silently clamped — the operator must see that their setting was refused.
|
||||
#[test]
|
||||
fn an_expensive_router_call_tier_is_rejected_rather_than_clamped() {
|
||||
for value in ["medium", "high", "max"] {
|
||||
let text = LUNA.replace("\"low\"", &format!("\"{value}\""));
|
||||
let err = ReasoningRouterProfile::parse(&text).expect_err("expensive tier");
|
||||
assert!(
|
||||
matches!(err, ReasoningRouterError::CallReasoningTooExpensive { .. }),
|
||||
"value={value} err={err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// And `low` is honored end to end, never forced to `off` behind the label.
|
||||
let profile = ReasoningRouterProfile::parse(LUNA).expect("parse");
|
||||
let plan = router_call_plan(profile.call_reasoning, &ReasoningCapability::tiered());
|
||||
assert_eq!(plan.tier, ReasoningTier::Low);
|
||||
assert_eq!(plan.disclosure.effective, "low");
|
||||
assert_eq!(plan.disclosure.provider_effective, "low");
|
||||
}
|
||||
|
||||
/// A manual tier consults no Router at all.
|
||||
#[test]
|
||||
fn a_non_auto_member_never_consults_the_router() {
|
||||
let document = FleetDocument::parse(GLM_FLEET).expect("parse");
|
||||
let exact = document.exact().expect("exact");
|
||||
let auditor = exact.member("auditor").expect("auditor");
|
||||
|
||||
let resolved = resolve_exact_member_reasoning(
|
||||
&auditor.id,
|
||||
&auditor.frozen_route(),
|
||||
auditor.reasoning,
|
||||
&ReasoningCapability::tiered(),
|
||||
// Deliberately absent — an explicit tier must not need a router.
|
||||
&RouterAvailability::Absent,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("explicit tier resolves");
|
||||
|
||||
assert_eq!(
|
||||
resolved.effective(),
|
||||
EffectiveReasoning::Tier(ReasoningTier::High)
|
||||
);
|
||||
assert_eq!(resolved.source(), EffectiveReasoningSource::MemberExplicit);
|
||||
assert!(resolved.router().is_none(), "no router was involved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_auto_member_in_a_router_less_fleet_fails_before_work_starts() {
|
||||
let router_less = GLM_FLEET.replace("reasoning_router = \"luna-low\"\n", "");
|
||||
let document = FleetDocument::parse(&router_less).expect("parse");
|
||||
let exact = document.exact().expect("exact");
|
||||
assert!(exact.router_ref().is_none());
|
||||
|
||||
let member = exact.member("implementer").expect("member");
|
||||
let err = resolve_exact_member_reasoning(
|
||||
&member.id,
|
||||
&member.frozen_route(),
|
||||
member.reasoning,
|
||||
&ReasoningCapability::tiered(),
|
||||
&RouterAvailability::Absent,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("auto without a router must fail closed");
|
||||
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("implementer"), "{message}");
|
||||
assert!(message.contains("reasoning_router"), "{message}");
|
||||
}
|
||||
|
||||
/// A Fleet that references a Router profile which is not installed cannot be
|
||||
/// resolved — decided locally, with no provider contacted.
|
||||
#[test]
|
||||
fn a_missing_router_profile_is_a_local_load_failure() {
|
||||
let (_tmp, roots) = workspace_with(GLM_FLEET, None);
|
||||
let (document, _id) = FleetDocument::load_by_name("glm-pair", &roots).expect("fleet loads");
|
||||
let reference = document
|
||||
.exact()
|
||||
.expect("exact")
|
||||
.reasoning_router
|
||||
.clone()
|
||||
.expect("reference");
|
||||
|
||||
assert!(matches!(
|
||||
ReasoningRouterProfile::load_by_name(&reference, &roots).expect_err("missing"),
|
||||
ReasoningRouterError::NotFound { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_ceilings_clamp_against_a_read_only_session_posture() {
|
||||
let document = FleetDocument::parse(GLM_FLEET).expect("parse");
|
||||
let snapshot = FleetSnapshot::capture(
|
||||
fleet_id("glm-pair"),
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
Some(luna()),
|
||||
)
|
||||
.expect("capture");
|
||||
|
||||
let session = PermissionCeiling {
|
||||
write: false,
|
||||
network_tool: false,
|
||||
shell: ShellCeiling::ReadOnly,
|
||||
delegation_depth: 0,
|
||||
tools: true,
|
||||
};
|
||||
|
||||
let implementer = snapshot.member("implementer").expect("member");
|
||||
assert!(
|
||||
implementer.permissions.write,
|
||||
"the saved ceiling allows writes"
|
||||
);
|
||||
|
||||
let effective = implementer.permissions.clamp_to(session);
|
||||
assert!(
|
||||
!effective.write,
|
||||
"a saved fleet must never raise the active session posture"
|
||||
);
|
||||
assert_eq!(effective.shell, ShellCeiling::ReadOnly);
|
||||
}
|
||||
|
||||
/// The bounded routing summary is transmitted exactly once, and the receipt's
|
||||
/// count and hash describe exactly those bytes.
|
||||
#[test]
|
||||
fn the_routing_summary_is_transmitted_once_and_disclosed_without_content() {
|
||||
let payload = bounded_routing_payload("refactor the parser in /Users/hunter/app");
|
||||
let disclosure = payload.disclosure().clone();
|
||||
let input = codewhale_workflow::RouterCallInput {
|
||||
fleet: "workspace/glm-pair".to_string(),
|
||||
member_id: "implementer".to_string(),
|
||||
frozen: codewhale_workflow::FrozenRoute {
|
||||
provider: "zai".to_string(),
|
||||
model: "glm-5".to_string(),
|
||||
},
|
||||
payload,
|
||||
};
|
||||
|
||||
let system = router_system_prompt(&input);
|
||||
let user = router_user_message(&input);
|
||||
|
||||
assert!(!system.contains("refactor the parser"), "{system}");
|
||||
assert!(!user.contains("/Users/"), "paths are redacted: {user}");
|
||||
// The disclosed count and hash describe exactly the bytes that were sent —
|
||||
// and the summary appears exactly once across the whole request.
|
||||
assert_eq!(disclosure.transmitted_bytes, user.len());
|
||||
assert_eq!(disclosure.transmitted_chars, user.chars().count());
|
||||
assert_eq!(
|
||||
format!("{system}\n{user}").matches(user.as_str()).count(),
|
||||
1,
|
||||
"the bounded summary must be transmitted once, not duplicated"
|
||||
);
|
||||
assert!(disclosure.redacted);
|
||||
assert!(disclosure.redactions.contains(&"absolute_path".to_string()));
|
||||
}
|
||||
|
||||
/// Legacy role-map fleets keep loading through the same store.
|
||||
#[test]
|
||||
fn legacy_fleet_files_still_load_through_the_same_store() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..");
|
||||
let (document, id) =
|
||||
FleetDocument::load_by_name("stopship", &[FleetSearchRoot::new("workspace", root)])
|
||||
.expect("workspace legacy fleet loads");
|
||||
|
||||
assert!(document.is_legacy());
|
||||
assert_eq!(document.schema_kind(), "legacy");
|
||||
assert_eq!(id.qualified(), "workspace/stopship");
|
||||
let legacy = document.legacy().expect("legacy body");
|
||||
legacy.validate_stopship_roles().expect("required roles");
|
||||
assert_eq!(legacy.resolve("release_lead").unwrap(), "manager");
|
||||
}
|
||||
|
||||
/// A personal `~/.codewhale` Fleet must not silently shadow — or be shadowed
|
||||
/// by — a project Fleet of the same name.
|
||||
#[test]
|
||||
fn an_exact_fleet_defined_in_two_origins_is_ambiguous_until_qualified() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let home = tmp.path().join("home");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
for root in [&home, &workspace] {
|
||||
std::fs::create_dir_all(root.join("fleets")).expect("fleets dir");
|
||||
}
|
||||
std::fs::write(
|
||||
home.join("fleets/glm-pair.toml"),
|
||||
GLM_FLEET.replace("model = \"glm-5\"", "model = \"glm-4\""),
|
||||
)
|
||||
.expect("home fleet");
|
||||
std::fs::write(workspace.join("fleets/glm-pair.toml"), GLM_FLEET).expect("workspace fleet");
|
||||
|
||||
let roots = vec![
|
||||
FleetSearchRoot::new("codewhale_home", &home),
|
||||
FleetSearchRoot::new("workspace", &workspace),
|
||||
];
|
||||
|
||||
let err = FleetDocument::load_by_name("glm-pair", &roots)
|
||||
.expect_err("an exact fleet must not be resolved by shadowing");
|
||||
assert!(
|
||||
matches!(err, NamedFleetError::AmbiguousFleet { .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
|
||||
let (document, id) =
|
||||
FleetDocument::load_by_name("workspace/glm-pair", &roots).expect("qualified load");
|
||||
assert_eq!(id.qualified(), "workspace/glm-pair");
|
||||
assert_eq!(
|
||||
document
|
||||
.exact()
|
||||
.expect("exact")
|
||||
.member("implementer")
|
||||
.expect("member")
|
||||
.model,
|
||||
"glm-5"
|
||||
);
|
||||
|
||||
let (home_document, home_id) =
|
||||
FleetDocument::load_by_name("codewhale_home/glm-pair", &roots).expect("qualified load");
|
||||
assert_eq!(home_id.qualified(), "codewhale_home/glm-pair");
|
||||
assert_eq!(
|
||||
home_document
|
||||
.exact()
|
||||
.expect("exact")
|
||||
.member("implementer")
|
||||
.expect("member")
|
||||
.model,
|
||||
"glm-4"
|
||||
);
|
||||
}
|
||||
|
||||
/// Legacy role maps keep their historic first-hit-wins behavior: a role map
|
||||
/// resolves through the same profile store from either origin.
|
||||
#[test]
|
||||
fn legacy_fleets_in_two_origins_keep_first_hit_wins() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let home = tmp.path().join("home");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
for root in [&home, &workspace] {
|
||||
std::fs::create_dir_all(root.join("fleets")).expect("fleets dir");
|
||||
}
|
||||
std::fs::write(
|
||||
home.join("fleets/pair.toml"),
|
||||
"name = \"pair\"\n\n[roles]\nscout = \"home-scout\"\n",
|
||||
)
|
||||
.expect("home fleet");
|
||||
std::fs::write(
|
||||
workspace.join("fleets/pair.toml"),
|
||||
"name = \"pair\"\n\n[roles]\nscout = \"workspace-scout\"\n",
|
||||
)
|
||||
.expect("workspace fleet");
|
||||
|
||||
let (document, id) = FleetDocument::load_by_name(
|
||||
"pair",
|
||||
&[
|
||||
FleetSearchRoot::new("codewhale_home", &home),
|
||||
FleetSearchRoot::new("workspace", &workspace),
|
||||
],
|
||||
)
|
||||
.expect("legacy collisions stay resolvable");
|
||||
|
||||
assert!(document.is_legacy());
|
||||
assert_eq!(id.origin, "codewhale_home");
|
||||
assert_eq!(
|
||||
document.legacy().expect("legacy").resolve("scout").unwrap(),
|
||||
"home-scout"
|
||||
);
|
||||
}
|
||||
|
||||
/// A broken file in a *shadowed* origin must not fail a legacy load that has
|
||||
/// always worked.
|
||||
#[test]
|
||||
fn a_malformed_shadowed_sibling_does_not_regress_legacy_first_hit() {
|
||||
let tmp = tempfile::tempdir().expect("tmp");
|
||||
let home = tmp.path().join("home");
|
||||
let workspace = tmp.path().join("workspace");
|
||||
for root in [&home, &workspace] {
|
||||
std::fs::create_dir_all(root.join("fleets")).expect("fleets dir");
|
||||
}
|
||||
std::fs::write(
|
||||
home.join("fleets/pair.toml"),
|
||||
"name = \"pair\"\n\n[roles]\nscout = \"home-scout\"\n",
|
||||
)
|
||||
.expect("home fleet");
|
||||
std::fs::write(
|
||||
workspace.join("fleets/pair.toml"),
|
||||
"name = \"pair\"\n[roles\nscout = = = \"\"\"broken\n",
|
||||
)
|
||||
.expect("workspace fleet");
|
||||
|
||||
let (document, id) = FleetDocument::load_by_name(
|
||||
"pair",
|
||||
&[
|
||||
FleetSearchRoot::new("codewhale_home", &home),
|
||||
FleetSearchRoot::new("workspace", &workspace),
|
||||
],
|
||||
)
|
||||
.expect("a broken shadowed sibling must not break first-hit-wins");
|
||||
|
||||
assert_eq!(id.origin, "codewhale_home");
|
||||
assert_eq!(
|
||||
document.legacy().expect("legacy").resolve("scout").unwrap(),
|
||||
"home-scout"
|
||||
);
|
||||
}
|
||||
|
||||
/// Roster invariants hold at the crate boundary, not just inside the parser.
|
||||
#[test]
|
||||
fn duplicate_roles_and_reserved_router_identities_are_rejected_at_load() {
|
||||
let duplicate_role = GLM_FLEET.replace("role = \"reviewer\"", "role = \"builder\"");
|
||||
assert!(
|
||||
FleetDocument::parse(&duplicate_role).is_err(),
|
||||
"two members must not share the role `builder`"
|
||||
);
|
||||
|
||||
let worker_router = GLM_FLEET.replace("role = \"reviewer\"", "role = \"router\"");
|
||||
assert!(
|
||||
FleetDocument::parse(&worker_router).is_err(),
|
||||
"a worker must not claim the reserved role `router`"
|
||||
);
|
||||
}
|
||||
|
||||
/// The legacy inline Router form still parses and normalizes into the same
|
||||
/// captured service — one runtime representation, whichever way it was written.
|
||||
#[test]
|
||||
fn a_legacy_inline_router_still_works_and_normalizes() {
|
||||
let inline = format!(
|
||||
"{}\n[[members]]\nid = \"router\"\nkind = \"router\"\nprovider = \"zai\"\nmodel = \
|
||||
\"glm-5-turbo\"\n",
|
||||
GLM_FLEET.replace("reasoning_router = \"luna-low\"\n", "")
|
||||
);
|
||||
let document = FleetDocument::parse(&inline).expect("legacy inline parses");
|
||||
let exact = document.exact().expect("exact");
|
||||
let captured = codewhale_workflow::captured_legacy_inline_router(exact).expect("inline router");
|
||||
|
||||
assert!(captured.legacy_inline);
|
||||
assert_eq!(captured.service_kind, REASONING_ROUTER_SERVICE_KIND);
|
||||
assert_eq!(captured.route.model, "glm-5-turbo");
|
||||
assert_eq!(captured.requested_call_reasoning, RouterCallReasoning::Off);
|
||||
assert!(!captured.is_dispatchable());
|
||||
|
||||
let snapshot = FleetSnapshot::capture(
|
||||
fleet_id("glm-pair"),
|
||||
&document,
|
||||
"2026-07-26T00:00:00Z",
|
||||
Some(captured),
|
||||
)
|
||||
.expect("capture");
|
||||
assert!(snapshot.member("router").is_none());
|
||||
assert_eq!(snapshot.members().len(), 2);
|
||||
}
|
||||
|
||||
/// The provider-effective control is reported separately from the selector
|
||||
/// tier: a Z.AI GLM route expresses only thinking on/off, so `high` and `max`
|
||||
/// must not be presented as two distinct provider-effective tiers.
|
||||
#[test]
|
||||
fn glm_receipts_do_not_invent_distinct_high_and_max_provider_tiers() {
|
||||
let document = FleetDocument::parse(GLM_FLEET).expect("parse");
|
||||
let exact = document.exact().expect("exact");
|
||||
let auditor = exact.member("auditor").expect("auditor");
|
||||
let glm = ReasoningCapability::enabled_disabled();
|
||||
|
||||
let high = resolve_exact_member_reasoning(
|
||||
&auditor.id,
|
||||
&auditor.frozen_route(),
|
||||
RequestedReasoning::High,
|
||||
&glm,
|
||||
&RouterAvailability::Absent,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("resolve");
|
||||
let max = resolve_exact_member_reasoning(
|
||||
&auditor.id,
|
||||
&auditor.frozen_route(),
|
||||
RequestedReasoning::Max,
|
||||
&glm,
|
||||
&RouterAvailability::Absent,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("resolve");
|
||||
|
||||
assert_eq!(
|
||||
high.provider_effective(),
|
||||
ProviderEffectiveReasoning::Enabled
|
||||
);
|
||||
assert_eq!(
|
||||
max.provider_effective(),
|
||||
ProviderEffectiveReasoning::Enabled
|
||||
);
|
||||
assert_ne!(high.effective(), max.effective());
|
||||
}
|
||||
Reference in New Issue
Block a user