1use crate::cost::{
15 DEFAULT_CODEX_MODEL, DEFAULT_CURSOR_MODEL, DEFAULT_DROID_MODEL, DEFAULT_KIMI_MODEL,
16};
17use crate::error::{EngineError, Result};
18use crate::paths;
19use crate::types::{BackendKind, ExecutorTier, MissionConfig, Role, SandboxEnforce};
20use std::path::{Path, PathBuf};
21
22const VALID_EFFORTS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
24
25pub const MAX_WORKER_CANDIDATES: usize = 8;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub enum ModelTier {
33 BelowDefault,
34 Default,
35 Frontier,
36}
37
38pub fn parse_backend(raw: Option<&str>) -> std::result::Result<BackendKind, String> {
40 match raw {
41 None | Some("claude") => Ok(BackendKind::Claude),
42 Some("codex") => Ok(BackendKind::Codex),
43 Some("droid") => Ok(BackendKind::Droid),
44 Some("kimi") => Ok(BackendKind::Kimi),
45 Some("local") => Ok(BackendKind::Local),
46 Some("acp") => Ok(BackendKind::Acp),
47 Some("cursor") => Ok(BackendKind::Cursor),
48 Some(other) => Err(other.to_string()),
49 }
50}
51
52pub fn task_class_to_tier(task_class: Option<&str>) -> ExecutorTier {
57 match task_class.map(|s| s.trim().to_ascii_lowercase()) {
58 Some(ref s) if s == "execution-class" => ExecutorTier::Local,
59 _ => ExecutorTier::Frontier,
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
67pub struct LocalEndpoint {
68 pub base_url: String,
69 pub context_budget: u32,
70 pub temperature: Option<f64>,
71}
72
73pub fn apply_executor_routing(
86 config: &mut MissionConfig,
87 tier: ExecutorTier,
88 local_endpoint: Option<&LocalEndpoint>,
89) -> ExecutorTier {
90 if !config.worker_candidates.is_empty() {
91 return ExecutorTier::Frontier;
92 }
93 match (tier, local_endpoint) {
94 (ExecutorTier::Frontier, _) => ExecutorTier::Frontier,
95 (ExecutorTier::Local, None) => ExecutorTier::Frontier,
96 (ExecutorTier::Local, Some(endpoint)) => {
97 config.worker.backend = Some("local".to_string());
98 config.worker.base_url = Some(endpoint.base_url.clone());
99 config.worker.context_budget = Some(endpoint.context_budget);
100 config.worker.temperature = endpoint.temperature;
101 config.allow_below_default_worker_model = true;
102 ExecutorTier::Local
103 }
104 }
105}
106
107pub fn route_ticket_executor(
114 cfg: &mut MissionConfig,
115 ticket: &crate::ticket::Ticket,
116) -> (ExecutorTier, &'static str) {
117 route_task_class_executor(cfg, ticket.task_class.as_deref())
118}
119
120pub fn route_task_class_executor(
133 cfg: &mut MissionConfig,
134 task_class: Option<&str>,
135) -> (ExecutorTier, &'static str) {
136 let table_configured = !cfg.routing.is_empty();
137 let requested = if table_configured {
138 crate::routing::table_tier(&cfg.routing, task_class)
139 } else {
140 task_class_to_tier(task_class)
141 };
142 let local_endpoint = match (&cfg.worker.base_url, cfg.worker.context_budget) {
143 (Some(base_url), Some(context_budget)) => Some(LocalEndpoint {
144 base_url: base_url.clone(),
145 context_budget,
146 temperature: cfg.worker.temperature,
147 }),
148 _ => None,
149 };
150 let applied = apply_executor_routing(cfg, requested, local_endpoint.as_ref());
151 let summary = match (requested, applied, table_configured) {
152 (ExecutorTier::Local, ExecutorTier::Local, true) => {
153 "executor routed local (routing-table rule)"
154 }
155 (ExecutorTier::Local, ExecutorTier::Local, false) => {
156 "executor routed local (execution-class)"
157 }
158 (ExecutorTier::Local, ExecutorTier::Frontier, true) => {
159 "routing-table rule routes local but no local endpoint configured; executor stays frontier"
160 }
161 (ExecutorTier::Local, ExecutorTier::Frontier, false) => {
162 "execution-class ticket but no local endpoint configured; executor stays frontier"
163 }
164 _ => "executor stays frontier",
165 };
166 (applied, summary)
167}
168
169fn backend_default_model(kind: BackendKind) -> Option<&'static str> {
174 match kind {
175 BackendKind::Claude => None,
176 BackendKind::Codex => Some(DEFAULT_CODEX_MODEL),
177 BackendKind::Droid => Some(DEFAULT_DROID_MODEL),
178 BackendKind::Kimi => Some(DEFAULT_KIMI_MODEL),
179 BackendKind::Local => None,
180 BackendKind::Acp => None,
184 BackendKind::Cursor => Some(DEFAULT_CURSOR_MODEL),
185 }
186}
187
188fn role_default_model(role: Role) -> &'static str {
189 match role {
190 Role::Orchestrator | Role::ValidatorScrutiny => "opus",
191 Role::Worker | Role::ValidatorFunctional => "sonnet",
192 }
193}
194
195pub fn effective_model(role: Role, kind: BackendKind, configured: &str) -> String {
202 if kind != BackendKind::Claude && configured == role_default_model(role) {
203 if let Some(default_model) = backend_default_model(kind) {
204 return default_model.to_string();
205 }
206 }
207 configured.to_string()
208}
209
210pub fn model_tier(kind: BackendKind, model: &str) -> Option<ModelTier> {
213 let m = model.trim().to_ascii_lowercase();
214 if m.is_empty() {
215 return None;
216 }
217 match kind {
218 BackendKind::Claude => {
219 if m == "haiku" || m.contains("haiku") {
220 Some(ModelTier::BelowDefault)
221 } else if m == "sonnet" || m.contains("sonnet") {
222 Some(ModelTier::Default)
223 } else if m == "opus" || m.contains("opus") || m == "fable" || m.contains("fable") {
224 Some(ModelTier::Frontier)
225 } else {
226 None
227 }
228 }
229 BackendKind::Codex => {
230 if m == "codex" || m == DEFAULT_CODEX_MODEL || m.starts_with("gpt-5") {
231 Some(ModelTier::Frontier)
232 } else {
233 None
234 }
235 }
236 BackendKind::Droid => {
237 if m == DEFAULT_DROID_MODEL || m.contains("glm") || m.contains("fireworks") {
238 Some(ModelTier::BelowDefault)
239 } else if m == "fable" || m.contains("fable") {
240 Some(ModelTier::Frontier)
241 } else {
242 None
243 }
244 }
245 BackendKind::Kimi => {
246 if m == DEFAULT_KIMI_MODEL {
247 Some(ModelTier::Frontier)
248 } else if m == "kimi-code/kimi-for-coding" || m == "kimi-code/kimi-for-coding-highspeed"
249 {
250 Some(ModelTier::BelowDefault)
251 } else {
252 None
253 }
254 }
255 BackendKind::Local => Some(ModelTier::BelowDefault),
260 BackendKind::Acp => Some(ModelTier::BelowDefault),
264 BackendKind::Cursor => Some(ModelTier::BelowDefault),
273 }
274}
275
276pub fn role_model_tier(cfg: &MissionConfig, role: Role) -> Option<ModelTier> {
279 let kind = cfg.backend_kind(role);
280 let model = effective_model(role, kind, &cfg.role(role).model);
281 model_tier(kind, &model)
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum Layer {
305 Global,
307 Project,
309}
310
311const ROLE_KEYS: [&str; 4] = [
314 "orchestrator",
315 "worker",
316 "validatorScrutiny",
317 "validatorFunctional",
318];
319
320fn normalize_key_path(dotted: &str) -> String {
323 let mut segments: Vec<&str> = dotted.split('.').collect();
324 if let Some(first) = segments.first_mut() {
325 if ROLE_KEYS.contains(first) {
326 *first = "<role>";
327 }
328 }
329 segments.join(".")
330}
331
332fn path_matches(normalized: &str, key: &str) -> bool {
334 normalized == key
335 || (normalized.len() > key.len()
336 && normalized.starts_with(key)
337 && normalized.as_bytes()[key.len()] == b'.')
338}
339
340#[derive(Clone, Copy)]
344enum ProjectPolicy {
345 OperatorOnly,
346 SandboxFloor,
347 ReviewerFloor,
348}
349
350#[derive(Clone, Copy)]
351enum PatchClass {
352 Runtime,
353 Consent,
354 SandboxFloor,
355 Never,
356}
357
358struct ConfigTrustRule {
359 key: &'static str,
360 project: ProjectPolicy,
361 runtime: PatchClass,
362 reason: &'static str,
363}
364
365impl ConfigTrustRule {
366 const fn operator_only(key: &'static str, runtime: PatchClass, reason: &'static str) -> Self {
367 Self {
368 key,
369 project: ProjectPolicy::OperatorOnly,
370 runtime,
371 reason,
372 }
373 }
374}
375
376const CONFIG_TRUST_RULES: &[ConfigTrustRule] = &[
377 ConfigTrustRule::operator_only(
382 "skipScrutiny",
383 PatchClass::Consent,
384 "it removes the scrutiny validation round",
385 ),
386 ConfigTrustRule::operator_only(
387 "skipFunctional",
388 PatchClass::Consent,
389 "it removes the functional validation round",
390 ),
391 ConfigTrustRule::operator_only(
392 "denyPatterns",
393 PatchClass::Consent,
394 "it is the Bash deny list every session inherits",
395 ),
396 ConfigTrustRule::operator_only(
397 "<role>.tools",
398 PatchClass::Never,
399 "it lands in the session's tool allow list, including the read-only validators'",
400 ),
401 ConfigTrustRule::operator_only(
402 "allowBelowDefaultWorkerModel",
403 PatchClass::Runtime,
404 "it lifts the worker model floor the operator set",
405 ),
406 ConfigTrustRule::operator_only(
407 "workerIsolation",
408 PatchClass::Never,
409 "checkout isolation makes the repository root the worker's writable cwd",
410 ),
411 ConfigTrustRule::operator_only(
412 "claudeBinary",
413 PatchClass::Never,
414 "it names the binary kranz executes, with the operator's full environment and no sandbox",
415 ),
416 ConfigTrustRule::operator_only(
417 "packDir",
418 PatchClass::Never,
419 "the pack it names supplies shell gate commands the engine runs",
420 ),
421 ConfigTrustRule::operator_only(
422 "contractEnvPassthrough",
423 PatchClass::Never,
424 "it copies named ambient credentials verbatim into contract-command environments",
425 ),
426 ConfigTrustRule::operator_only(
427 "dangerouslyAllowAll",
428 PatchClass::Consent,
429 "it puts every agent session in bypassPermissions",
430 ),
431 ConfigTrustRule::operator_only(
432 "validatorAllowUncontainedDegrade",
433 PatchClass::Consent,
434 "it reopens the uncontained-validator degrade the containment work closed",
435 ),
436 ConfigTrustRule::operator_only(
437 "allowValidatorCommands",
438 PatchClass::Consent,
439 "it grants validators shell commands with no human step",
440 ),
441 ConfigTrustRule::operator_only(
442 "localBackendAllowedHosts",
443 PatchClass::Never,
444 "it is the operator's own escape hatch from the local-backend loopback rule",
445 ),
446 ConfigTrustRule::operator_only(
455 "slack",
456 PatchClass::Never,
457 "it carries the Slack bot and app tokens, and the channel mission output is posted to \
458 (the Slack bridge reads the global layer only)",
459 ),
460 ConfigTrustRule::operator_only(
461 "hookStatus",
462 PatchClass::Never,
463 "the per-run capability token rides its endpoint",
464 ),
465 ConfigTrustRule::operator_only(
466 "workspace.remote",
467 PatchClass::Never,
468 "it names a remote workspace URL and the env var holding its token",
469 ),
470 ConfigTrustRule::operator_only(
471 "<role>.acpProfile",
472 PatchClass::Never,
473 "it selects a qualified worker profile and the operator's credential source",
474 ),
475 ConfigTrustRule::operator_only(
476 "<role>.acpCommand",
477 PatchClass::Never,
478 "it names the ACP agent program",
479 ),
480 ConfigTrustRule::operator_only(
481 "<role>.acpArgs",
482 PatchClass::Never,
483 "it is argv for the ACP agent program",
484 ),
485 ConfigTrustRule::operator_only(
486 "<role>.baseUrl",
487 PatchClass::Never,
488 "the engine POSTs the assembled prompt to it from outside every sandbox",
489 ),
490 ConfigTrustRule::operator_only(
491 "<role>.sandbox.extraWrite",
492 PatchClass::Never,
493 "it widens the sandbox write allowlist",
494 ),
495 ConfigTrustRule::operator_only(
496 "<role>.sandbox.egress",
497 PatchClass::Never,
498 "it widens the sandbox egress allowlist",
499 ),
500 ConfigTrustRule::operator_only(
501 "<role>.sandbox.provider",
502 PatchClass::Never,
503 "it selects which containment mechanism wraps sessions",
504 ),
505 ConfigTrustRule::operator_only(
506 "<role>.sandbox.image",
507 PatchClass::Never,
508 "it names the container image sessions run inside",
509 ),
510 ConfigTrustRule {
511 key: "<role>.sandbox.enforce",
512 project: ProjectPolicy::SandboxFloor,
513 runtime: PatchClass::SandboxFloor,
514 reason: "a repository may raise sandbox enforcement, never lower it",
515 },
516 ConfigTrustRule {
517 key: "reviewerIndependence",
518 project: ProjectPolicy::ReviewerFloor,
519 runtime: PatchClass::Never,
520 reason: "a repository may strengthen reviewer independence, never weaken it",
521 },
522];
523
524fn config_trust_rule(normalized: &str) -> Option<&'static ConfigTrustRule> {
525 CONFIG_TRUST_RULES
526 .iter()
527 .find(|rule| path_matches(normalized, rule.key))
528}
529
530fn enforce_rank(value: Option<&serde_json::Value>) -> u8 {
534 use serde::Deserialize as _;
535 match value.and_then(|value| SandboxEnforce::deserialize(value).ok()) {
538 Some(SandboxEnforce::Fs) => 1,
539 Some(SandboxEnforce::FsNet) => 2,
540 Some(SandboxEnforce::Off) | None => 0,
541 }
542}
543
544fn project_layer_refusal(file: &Path, dotted: &str, reason: &str) -> EngineError {
545 EngineError::Config(format!(
546 "{}: the project config layer may not set {dotted:?} — {reason}. \
547 Operator-only keys are settable from the global layer \
548 (~/.kranz/config.json) only.",
549 file.display()
550 ))
551}
552
553pub fn check_project_layer_keys(
561 patch: &serde_json::Value,
562 base: &serde_json::Value,
563 file: &Path,
564) -> Result<()> {
565 if !patch.is_object() || !base.is_object() {
566 return Err(EngineError::Config(format!(
567 "{}: project config changes require JSON objects at the top level",
568 file.display()
569 )));
570 }
571 let mut trail: Vec<String> = Vec::new();
572 walk_project_layer(patch, Some(base), file, &mut trail)
573}
574
575fn check_project_reviewer_floor(
576 policy: &serde_json::Value,
577 base: Option<&serde_json::Value>,
578 file: &Path,
579 dotted: &str,
580 reason: &str,
581) -> Result<()> {
582 for role in ["scrutiny", "functional"] {
583 let required = base
584 .and_then(|policy| policy.get(role))
585 .and_then(serde_json::Value::as_bool)
586 == Some(true);
587 let retained = policy.is_object()
590 && policy
591 .get(role)
592 .is_none_or(|value| value.as_bool() == Some(true));
593 if required && !retained {
594 return Err(project_layer_refusal(
595 file,
596 &format!("{dotted}.{role}"),
597 reason,
598 ));
599 }
600 }
601 Ok(())
602}
603
604fn walk_project_layer(
605 patch: &serde_json::Value,
606 base: Option<&serde_json::Value>,
607 file: &Path,
608 trail: &mut Vec<String>,
609) -> Result<()> {
610 let serde_json::Value::Object(map) = patch else {
611 return Ok(());
612 };
613 for (key, value) in map {
614 trail.push(key.clone());
615 let dotted = trail.join(".");
616 let normalized = normalize_key_path(&dotted);
617
618 let base_value = base.and_then(|b| b.get(key));
619 let protected_container = CONFIG_TRUST_RULES.iter().any(|rule| {
623 (normalized != rule.key && path_matches(rule.key, &normalized))
624 || (normalized == rule.key && matches!(rule.project, ProjectPolicy::ReviewerFloor))
625 });
626 if protected_container {
627 if base_value.is_some_and(|base| !base.is_object()) {
628 return Err(EngineError::Config(format!(
629 "{}: cannot safely merge project config over non-object inherited field {dotted:?}; protected config containers must be JSON objects",
630 file.display()
631 )));
632 }
633 if !value.is_object() {
634 return Err(EngineError::Config(format!(
635 "{}: project config field {dotted:?} must be a JSON object; positional arrays and scalar replacements bypass protected child checks",
636 file.display()
637 )));
638 }
639 }
640 if let Some(rule) = config_trust_rule(&normalized) {
641 match rule.project {
642 ProjectPolicy::OperatorOnly => {
643 return Err(project_layer_refusal(file, &dotted, rule.reason));
644 }
645 ProjectPolicy::SandboxFloor if normalized == rule.key => {
646 if enforce_rank(Some(value)) < enforce_rank(base_value) {
647 return Err(project_layer_refusal(file, &dotted, rule.reason));
648 }
649 }
650 ProjectPolicy::ReviewerFloor if normalized == rule.key => {
651 check_project_reviewer_floor(value, base_value, file, &dotted, rule.reason)?;
652 }
653 _ => {}
654 }
655 }
656
657 walk_project_layer(value, base_value, file, trail)?;
658 trail.pop();
659 }
660 Ok(())
661}
662
663pub fn validate_claude_binary(cfg: &MissionConfig, repo_root: &Path) -> Result<()> {
673 let Some(raw) = cfg.claude_binary.as_deref() else {
674 return Ok(());
675 };
676 let trimmed = raw.trim();
677 if trimmed.is_empty() {
678 return Err(EngineError::Config(
679 "claudeBinary must not be empty; omit the key to auto-discover".into(),
680 ));
681 }
682 let candidate = Path::new(trimmed);
683 if !candidate.is_absolute() {
684 return Err(EngineError::Config(format!(
685 "claudeBinary {trimmed:?} must be an absolute path: a relative path resolves \
686 against the process working directory, so which program runs depends on where \
687 kranz was invoked"
688 )));
689 }
690 let candidate_forms = [
696 candidate.to_path_buf(),
697 std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()),
698 ];
699 let root_forms = [
700 repo_root.to_path_buf(),
701 std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf()),
702 ];
703 for resolved in &candidate_forms {
704 for root in &root_forms {
705 if resolved.starts_with(root) {
706 return Err(EngineError::Config(format!(
707 "claudeBinary {trimmed:?} resolves inside the repository at {}: repository \
708 content must never name the binary kranz executes",
709 root.display()
710 )));
711 }
712 }
713 }
714 Ok(())
715}
716
717pub fn load(repo_root: &Path) -> Result<MissionConfig> {
723 let mut layers: Vec<(PathBuf, Layer)> = Vec::new();
724 if let Some(global) = paths::global_config() {
725 layers.push((global, Layer::Global));
726 }
727 layers.push((paths::project_config(repo_root), Layer::Project));
728 let cfg = load_layers_with_roles(&layers)?;
729 validate_claude_binary(&cfg, repo_root)?;
730 Ok(cfg)
731}
732
733pub fn load_layers(layers: &[PathBuf]) -> Result<MissionConfig> {
741 let with_roles: Vec<(PathBuf, Layer)> = layers
742 .iter()
743 .map(|path| (path.clone(), Layer::Global))
744 .collect();
745 load_layers_with_roles(&with_roles)
746}
747
748pub fn load_layers_with_roles(layers: &[(PathBuf, Layer)]) -> Result<MissionConfig> {
753 let mut merged = serde_json::to_value(MissionConfig::default())?;
754
755 for (path, layer) in layers {
756 let text = match std::fs::read_to_string(path) {
757 Ok(text) => text,
758 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
760 Err(e) => {
761 return Err(EngineError::Config(format!(
762 "cannot read config file {}: {e}",
763 path.display()
764 )))
765 }
766 };
767
768 let patch: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
769 EngineError::Config(format!(
770 "invalid JSON in config file {}: {e}",
771 path.display()
772 ))
773 })?;
774
775 if !patch.is_object() {
776 return Err(EngineError::Config(format!(
777 "config file {} must contain a JSON object at the top level",
778 path.display()
779 )));
780 }
781
782 if *layer == Layer::Project {
783 check_project_layer_keys(&patch, &merged, path)?;
784 }
785
786 deep_merge(&mut merged, &patch);
787 }
788
789 serde_json::from_value(merged)
790 .map_err(|e| EngineError::Config(format!("merged configuration does not deserialize: {e}")))
791}
792
793pub fn deep_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
798 match (base, patch) {
799 (serde_json::Value::Object(base_map), serde_json::Value::Object(patch_map)) => {
800 for (key, patch_val) in patch_map {
801 match base_map.get_mut(key) {
802 Some(slot) => deep_merge(slot, patch_val),
803 None => {
804 base_map.insert(key.clone(), patch_val.clone());
805 }
806 }
807 }
808 }
809 (slot, patch_val) => *slot = patch_val.clone(),
810 }
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
826pub enum PatchSource {
827 Operator,
830 Inbox,
832}
833
834const RUNTIME_PATCHABLE: &[&str] = &[
840 "maxFixCyclesPerMilestone",
841 "maxRespawns",
842 "maxParallelWorkers",
843 "eventStreamThrottleMs",
844 "planningIdleReleaseMinutes",
845 "autoWork",
846 "consideredAlternativesFeatureThreshold",
847 "consideredAlternativesTouchSetThreshold",
848 "consideredAlternativesHighUsdThreshold",
849 "rubberStampThresholdMs",
850 "<role>.model",
851 "<role>.backend",
852 "<role>.reasoningEffort",
853 "<role>.maxTurns",
854 "<role>.maxBudgetUsd",
855 "<role>.contextBudget",
856 "<role>.temperature",
857];
858
859fn classify_patch_key(normalized: &str) -> (PatchClass, &'static str) {
860 if let Some(rule) = config_trust_rule(normalized) {
861 let class = match rule.runtime {
864 PatchClass::SandboxFloor if normalized != rule.key => PatchClass::Never,
865 class => class,
866 };
867 return (class, rule.reason);
868 }
869 if RUNTIME_PATCHABLE
870 .iter()
871 .any(|key| path_matches(normalized, key))
872 {
873 return (PatchClass::Runtime, "");
874 }
875 (PatchClass::Never, "")
876}
877
878pub fn check_runtime_patch(
886 patch: &serde_json::Value,
887 base: &serde_json::Value,
888 source: PatchSource,
889) -> Result<()> {
890 let mut trail: Vec<String> = Vec::new();
891 walk_runtime_patch(patch, Some(base), source, &mut trail)
892}
893
894fn walk_runtime_patch(
895 patch: &serde_json::Value,
896 base: Option<&serde_json::Value>,
897 source: PatchSource,
898 trail: &mut Vec<String>,
899) -> Result<()> {
900 if let serde_json::Value::Object(map) = patch {
901 for (key, value) in map {
902 trail.push(key.clone());
903 walk_runtime_patch(value, base.and_then(|b| b.get(key)), source, trail)?;
904 trail.pop();
905 }
906 return Ok(());
907 }
908
909 let dotted = trail.join(".");
910 let normalized = normalize_key_path(&dotted);
911
912 let (class, reason) = classify_patch_key(&normalized);
913 match class {
914 PatchClass::SandboxFloor => {
915 if enforce_rank(Some(patch)) >= enforce_rank(base) {
916 return Ok(());
917 }
918 match source {
919 PatchSource::Operator => Ok(()),
920 PatchSource::Inbox => Err(EngineError::Config(format!(
921 "refusing a control-inbox config change to {dotted:?}: lowering sandbox \
922 enforcement is a consent act, and the control inbox is an \
923 unauthenticated filesystem channel"
924 ))),
925 }
926 }
927 PatchClass::Runtime => Ok(()),
928 PatchClass::Consent => match source {
929 PatchSource::Operator => Ok(()),
930 PatchSource::Inbox => Err(EngineError::Config(format!(
931 "refusing a control-inbox config change to {dotted:?}: {reason}, so it is a \
932 human decision — the control inbox is an unauthenticated filesystem \
933 channel and cannot carry consent"
934 ))),
935 },
936 PatchClass::Never => Err(EngineError::Config(format!(
937 "{dotted:?} is not runtime-patchable: it is seed-time or operator-file \
938 configuration (a program, an endpoint, a credential, or the containment \
939 shape), not a mission knob"
940 ))),
941 }
942}
943
944pub fn apply_validated_patch(
955 current: &MissionConfig,
956 patch: &serde_json::Value,
957) -> Result<MissionConfig> {
958 apply_validated_patch_from(current, patch, PatchSource::Operator)
959}
960
961pub fn apply_validated_patch_from(
965 current: &MissionConfig,
966 patch: &serde_json::Value,
967 source: PatchSource,
968) -> Result<MissionConfig> {
969 let mut value = serde_json::to_value(current)?;
970 check_runtime_patch(patch, &value, source)?;
971 deep_merge(&mut value, patch);
972 let merged: MissionConfig = serde_json::from_value(value)
973 .map_err(|e| EngineError::Config(format!("patch produces invalid config: {e}")))?;
974 validate(&merged)?;
975 Ok(merged)
976}
977
978fn base_url_host(url: &str) -> &str {
982 let Some(rest) = url
983 .strip_prefix("http://")
984 .or_else(|| url.strip_prefix("https://"))
985 else {
986 return "";
987 };
988 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
989 let after_userinfo = match authority.rfind('@') {
990 Some(idx) => &authority[idx + 1..],
991 None => authority,
992 };
993 if let Some(bracketed) = after_userinfo.strip_prefix('[') {
994 return match bracketed.split_once(']') {
995 Some((host, _)) => host,
996 None => "",
997 };
998 }
999 after_userinfo.split(':').next().unwrap_or(after_userinfo)
1000}
1001
1002fn host_is_loopback(host: &str) -> bool {
1004 host.eq_ignore_ascii_case("localhost")
1005 || host
1006 .parse::<std::net::IpAddr>()
1007 .is_ok_and(|ip| ip.is_loopback())
1008}
1009
1010pub fn validate(cfg: &MissionConfig) -> Result<()> {
1013 crate::reviewer_independence::validate_config(cfg)?;
1014 let roles = [
1015 ("orchestrator", &cfg.orchestrator),
1016 ("worker", &cfg.worker),
1017 ("validatorScrutiny", &cfg.validator_scrutiny),
1018 ("validatorFunctional", &cfg.validator_functional),
1019 ];
1020 for (name, role) in roles {
1021 if !VALID_EFFORTS.contains(&role.reasoning_effort.as_str()) {
1022 return Err(EngineError::Config(format!(
1023 "{name}.reasoningEffort must be one of {VALID_EFFORTS:?}, got {:?}",
1024 role.reasoning_effort
1025 )));
1026 }
1027 }
1028
1029 if cfg.max_fix_cycles_per_milestone < 1 {
1030 return Err(EngineError::Config(
1031 "maxFixCyclesPerMilestone must be at least 1".into(),
1032 ));
1033 }
1034
1035 if cfg.max_respawns > 5 {
1036 return Err(EngineError::Config(format!(
1037 "maxRespawns must be at most 5, got {}",
1038 cfg.max_respawns
1039 )));
1040 }
1041
1042 if !(10..=5000).contains(&cfg.event_stream_throttle_ms) {
1043 return Err(EngineError::Config(format!(
1044 "eventStreamThrottleMs must be in 10..=5000, got {}",
1045 cfg.event_stream_throttle_ms
1046 )));
1047 }
1048 if !cfg.considered_alternatives_high_usd_threshold.is_finite()
1049 || cfg.considered_alternatives_high_usd_threshold < 0.0
1050 {
1051 return Err(EngineError::Config(format!(
1052 "consideredAlternativesHighUsdThreshold must be finite and non-negative, got {}",
1053 cfg.considered_alternatives_high_usd_threshold
1054 )));
1055 }
1056
1057 if !(1..=8).contains(&cfg.max_parallel_workers) {
1064 return Err(EngineError::Config(format!(
1065 "maxParallelWorkers must be in 1..=8 (1 = sequential; >1 opts into M3 \
1066 parallel workers), got {}",
1067 cfg.max_parallel_workers
1068 )));
1069 }
1070
1071 if cfg.worker_candidates.len() == 1 {
1079 return Err(EngineError::Config(
1080 "workerCandidates with exactly one entry is a roundabout worker.backend; \
1081 use worker.backend (the pool exists for N >= 2 heterogeneous candidates)"
1082 .into(),
1083 ));
1084 }
1085 if cfg.worker_candidates.len() > MAX_WORKER_CANDIDATES {
1086 return Err(EngineError::Config(format!(
1087 "workerCandidates supports at most {MAX_WORKER_CANDIDATES} candidates, got {}",
1088 cfg.worker_candidates.len()
1089 )));
1090 }
1091 if !cfg.worker_candidates.is_empty() && cfg.max_parallel_workers > 1 {
1096 return Err(EngineError::Config(
1097 "workerCandidates (dispatch pool: one unit to N backends) and \
1098 maxParallelWorkers > 1 (M3: N independent units concurrently) are mutually \
1099 exclusive in this pass; configure one fan-out model"
1100 .into(),
1101 ));
1102 }
1103 if cfg.worker.acp_profile.is_some() && !cfg.worker_candidates.is_empty() {
1104 return Err(EngineError::Config(
1105 "qualified ACP profiles cannot be combined with workerCandidates".into(),
1106 ));
1107 }
1108 for (i, candidate) in cfg.worker_candidates.iter().enumerate() {
1109 let kind = parse_backend(Some(&candidate.backend)).map_err(|other| {
1110 EngineError::Config(format!(
1111 "workerCandidates[{i}].backend must be one of \"claude\", \"codex\", \"droid\", \"kimi\", \"cursor\", got {other:?}"
1112 ))
1113 })?;
1114 if matches!(kind, BackendKind::Local | BackendKind::Acp) {
1118 return Err(EngineError::Config(format!(
1119 "workerCandidates[{i}].backend {:?} is not supported in this pass: local/acp \
1120 need per-candidate endpoint/command config (a deliberate widening); use \
1121 claude, codex, droid, kimi, or cursor candidates",
1122 candidate.backend
1123 )));
1124 }
1125 if cfg.worker.sandbox.enforce != SandboxEnforce::Off && !kind.supports_sandbox_enforcement()
1129 {
1130 return Err(EngineError::Config(format!(
1131 "workerCandidates[{i}].backend {:?} cannot honor sandbox.enforce={:?}: only the \
1132 claude backend applies the resolved OS sandbox; run with sandbox.enforce=off, \
1133 or drop the non-claude candidate",
1134 candidate.backend,
1135 cfg.worker.sandbox.enforce.as_str()
1136 )));
1137 }
1138 let effective = effective_model(Role::Worker, kind, &candidate.model);
1139 let tier = model_tier(kind, &effective).ok_or_else(|| {
1140 EngineError::Config(format!(
1141 "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1142 candidate.model,
1143 candidate.backend
1144 ))
1145 })?;
1146 if tier < ModelTier::Default && !cfg.allow_below_default_worker_model {
1149 return Err(EngineError::Config(format!(
1150 "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1151 allowBelowDefaultWorkerModel=true on this mission to opt in",
1152 candidate.model,
1153 candidate.backend
1154 )));
1155 }
1156 }
1157
1158 if let Err(err) = crate::routing::validate_table(&cfg.routing) {
1165 return Err(EngineError::Config(err));
1166 }
1167
1168 if let Some(hook_status) = &cfg.hook_status {
1174 if hook_status.enabled {
1175 if hook_status.endpoint.trim().is_empty() {
1176 return Err(EngineError::Config(
1177 "hookStatus.enabled requires hookStatus.endpoint (the loopback signal \
1178 POST URL, e.g. http://127.0.0.1:4560/api/hook-status)"
1179 .to_string(),
1180 ));
1181 }
1182 if !crate::hook_status::endpoint_is_loopback_http(&hook_status.endpoint) {
1183 return Err(EngineError::Config(format!(
1184 "hookStatus.endpoint must be a loopback http(s) URL (the per-run \
1185 capability token rides it), got {:?}",
1186 hook_status.endpoint
1187 )));
1188 }
1189 }
1190 }
1191
1192 for (role, name) in [
1193 (Role::Orchestrator, "orchestrator"),
1194 (Role::Worker, "worker"),
1195 (Role::ValidatorScrutiny, "validatorScrutiny"),
1196 (Role::ValidatorFunctional, "validatorFunctional"),
1197 ] {
1198 let role_cfg = cfg.role(role);
1199 let kind = parse_backend(role_cfg.backend.as_deref()).map_err(|other| {
1200 EngineError::Config(format!(
1201 "{name}.backend must be one of None, \"claude\", \"codex\", \"droid\", \"kimi\", \"local\", \"acp\", \"cursor\", got {other:?}"
1202 ))
1203 })?;
1204 let qualified_acp = if let Some(profile) = &role_cfg.acp_profile {
1208 profile.validate_config(role, role_cfg, cfg.worker_isolation)?;
1209 true
1210 } else {
1211 false
1212 };
1213 if role_cfg.sandbox.enforce != SandboxEnforce::Off
1214 && !kind.supports_sandbox_enforcement()
1215 && !qualified_acp
1216 {
1217 return Err(EngineError::Config(format!(
1218 "{name}.backend {:?} cannot honor sandbox.enforce={:?}: use the claude backend \
1219 or a qualified ACP worker profile; sandbox.enforce=off is explicitly unsandboxed",
1220 kind.as_str(),
1221 role_cfg.sandbox.enforce.as_str()
1222 )));
1223 }
1224 let effective = effective_model(role, kind, &role_cfg.model);
1225 let tier = model_tier(kind, &effective).ok_or_else(|| {
1226 EngineError::Config(format!(
1227 "{name} effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1228 role_cfg.model,
1229 kind.as_str()
1230 ))
1231 })?;
1232
1233 if kind == BackendKind::Local {
1234 if role == Role::ValidatorScrutiny {
1251 return Err(EngineError::Config(format!(
1252 "{name}.backend \"local\" is rejected: scrutiny is judgment, not a \
1253 deterministic mechanical check, and the local validator tier is the \
1254 functional role only (KRZ-206b) — a local judgment PASS is the \
1255 silent-green failure mode the guarded role split exists to prevent"
1256 )));
1257 }
1258 match role_cfg.base_url.as_deref() {
1259 Some(url) if !url.trim().is_empty() => {
1260 if base_url_host(url).is_empty() {
1261 return Err(EngineError::Config(format!(
1262 "{name}.baseUrl {url:?} is not a valid http/https URL"
1263 )));
1264 }
1265 let host = base_url_host(url);
1276 if !host_is_loopback(host)
1277 && !cfg
1278 .local_backend_allowed_hosts
1279 .iter()
1280 .any(|allowed| allowed.trim().eq_ignore_ascii_case(host))
1281 {
1282 return Err(EngineError::Config(format!(
1283 "{name}.baseUrl host {host:?} is not loopback: the engine POSTs \
1284 the assembled prompt to it from outside every sandbox and takes \
1285 the reply as model output. Use a loopback endpoint, or name the \
1286 host in localBackendAllowedHosts in ~/.kranz/config.json (the \
1287 global layer only)"
1288 )));
1289 }
1290 }
1291 _ => {
1292 return Err(EngineError::Config(format!(
1293 "{name}.baseUrl is required when {name}.backend is \"local\""
1294 )));
1295 }
1296 }
1297
1298 match role_cfg.context_budget {
1299 Some(budget) if (1024..=200_000).contains(&budget) => {}
1300 Some(budget) => {
1301 return Err(EngineError::Config(format!(
1302 "{name}.contextBudget must be in 1024..=200000, got {budget}"
1303 )));
1304 }
1305 None => {
1306 return Err(EngineError::Config(format!(
1307 "{name}.contextBudget is required when {name}.backend is \"local\""
1308 )));
1309 }
1310 }
1311
1312 if let Some(temperature) = role_cfg.temperature {
1313 if !temperature.is_finite() || !(0.0..=2.0).contains(&temperature) {
1314 return Err(EngineError::Config(format!(
1315 "{name}.temperature must be finite and in 0.0..=2.0, got {temperature}"
1316 )));
1317 }
1318 }
1319 }
1320
1321 if kind == BackendKind::Acp {
1322 if role != Role::Worker {
1328 return Err(EngineError::Config(format!(
1329 "{name}.backend \"acp\" is supported for the worker role only in this pass \
1330 (KRZ-301); validators and the orchestrator stay on their existing backends"
1331 )));
1332 }
1333 match role_cfg.acp_command.as_deref() {
1334 None if qualified_acp => {}
1335 Some(command) if !command.trim().is_empty() => {}
1336 _ => {
1337 return Err(EngineError::Config(format!(
1338 "{name}.acpCommand is required when {name}.backend is \"acp\" \
1339 (the ACP agent executable; extra argv goes in {name}.acpArgs)"
1340 )));
1341 }
1342 }
1343 }
1344
1345 if kind == BackendKind::Kimi
1350 && effective == DEFAULT_KIMI_MODEL
1351 && !["low", "high", "max"].contains(&role_cfg.reasoning_effort.as_str())
1352 {
1353 return Err(EngineError::Config(format!(
1354 "{name}.reasoningEffort must be one of [\"low\", \"high\", \"max\"] for kimi model {DEFAULT_KIMI_MODEL:?}, got {:?}",
1355 role_cfg.reasoning_effort
1356 )));
1357 }
1358
1359 if role == Role::Worker
1360 && tier < ModelTier::Default
1361 && !cfg.allow_below_default_worker_model
1362 {
1363 return Err(EngineError::Config(format!(
1364 "worker effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1365 allowBelowDefaultWorkerModel=true on this mission to opt in",
1366 role_cfg.model,
1367 kind.as_str()
1368 )));
1369 }
1370
1371 if role == Role::Orchestrator && tier < ModelTier::Frontier {
1372 return Err(EngineError::Config(format!(
1373 "orchestrator effective model {effective:?} (configured as {:?}) on backend {:?} is below the frontier-model floor",
1374 role_cfg.model,
1375 kind.as_str()
1376 )));
1377 }
1378 }
1379
1380 Ok(())
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386
1387 #[test]
1388 fn default_planning_idle_release_minutes_is_30() {
1389 assert_eq!(MissionConfig::default().planning_idle_release_minutes, 30);
1390 }
1391
1392 #[test]
1393 fn default_serializes_camel_case_planning_idle_release_minutes() {
1394 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1395 assert_eq!(value["planningIdleReleaseMinutes"], 30);
1396 }
1397
1398 #[test]
1399 fn layer_overrides_planning_idle_release_minutes() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let layer_path = dir.path().join("config.json");
1402 std::fs::write(&layer_path, r#"{"planningIdleReleaseMinutes": 5}"#).unwrap();
1403
1404 let cfg = load_layers(&[layer_path]).unwrap();
1405 assert_eq!(cfg.planning_idle_release_minutes, 5);
1406 }
1407
1408 #[test]
1409 fn absent_key_in_layer_keeps_default() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let layer_path = dir.path().join("config.json");
1412 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1413
1414 let cfg = load_layers(&[layer_path]).unwrap();
1415 assert_eq!(cfg.planning_idle_release_minutes, 30);
1416 }
1417
1418 #[test]
1419 fn default_auto_work_is_false() {
1420 assert!(!MissionConfig::default().auto_work);
1421 }
1422
1423 #[test]
1424 fn default_serializes_camel_case_auto_work() {
1425 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1426 assert_eq!(value["autoWork"], false);
1427 }
1428
1429 #[test]
1430 fn default_serializes_camel_case_considered_alternatives_thresholds() {
1431 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1432 assert_eq!(value["consideredAlternativesFeatureThreshold"], 4);
1433 assert_eq!(value["consideredAlternativesTouchSetThreshold"], 4);
1434 assert_eq!(value["consideredAlternativesHighUsdThreshold"], 0.0);
1435 }
1436
1437 #[test]
1438 fn layer_overrides_considered_alternatives_thresholds() {
1439 let dir = tempfile::tempdir().unwrap();
1440 let layer_path = dir.path().join("config.json");
1441 std::fs::write(
1442 &layer_path,
1443 r#"{
1444 "consideredAlternativesFeatureThreshold": 2,
1445 "consideredAlternativesTouchSetThreshold": 3,
1446 "consideredAlternativesHighUsdThreshold": 9.5
1447 }"#,
1448 )
1449 .unwrap();
1450
1451 let cfg = load_layers(&[layer_path]).unwrap();
1452 assert_eq!(cfg.considered_alternatives_feature_threshold, 2);
1453 assert_eq!(cfg.considered_alternatives_touch_set_threshold, 3);
1454 assert_eq!(cfg.considered_alternatives_high_usd_threshold, 9.5);
1455 }
1456
1457 #[test]
1458 fn default_serializes_camel_case_worker_floor_opt_in() {
1459 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1460 assert_eq!(value["allowBelowDefaultWorkerModel"], false);
1461 }
1462
1463 #[test]
1464 fn layer_overrides_auto_work() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let layer_path = dir.path().join("config.json");
1467 std::fs::write(&layer_path, r#"{"autoWork": true}"#).unwrap();
1468
1469 let cfg = load_layers(&[layer_path]).unwrap();
1470 assert!(cfg.auto_work);
1471 }
1472
1473 #[test]
1479 fn validator_allow_uncontained_degrade_defaults_off_and_parses_opt_in() {
1480 assert!(!MissionConfig::default().validator_allow_uncontained_degrade);
1481 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1482 assert_eq!(value["validatorAllowUncontainedDegrade"], false);
1483
1484 let dir = tempfile::tempdir().unwrap();
1485 let layer_path = dir.path().join("config.json");
1486 std::fs::write(&layer_path, r#"{"validatorAllowUncontainedDegrade": true}"#).unwrap();
1487 let cfg = load_layers(&[layer_path]).unwrap();
1488 assert!(cfg.validator_allow_uncontained_degrade);
1489
1490 let layer_path = dir.path().join("config-old.json");
1493 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1494 let cfg = load_layers(&[layer_path]).unwrap();
1495 assert!(!cfg.validator_allow_uncontained_degrade);
1496 }
1497
1498 #[test]
1499 fn contract_env_passthrough_defaults_empty_and_parses_camel_case() {
1500 assert!(MissionConfig::default().contract_env_passthrough.is_empty());
1503 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1504 assert_eq!(value["contractEnvPassthrough"], serde_json::json!([]));
1505
1506 let dir = tempfile::tempdir().unwrap();
1507 let layer_path = dir.path().join("config.json");
1508 std::fs::write(
1509 &layer_path,
1510 r#"{"contractEnvPassthrough": ["NPM_TOKEN", "REGISTRY_BASIC_AUTH"]}"#,
1511 )
1512 .unwrap();
1513 let cfg = load_layers(&[layer_path]).unwrap();
1514 assert_eq!(
1515 cfg.contract_env_passthrough,
1516 vec!["NPM_TOKEN".to_string(), "REGISTRY_BASIC_AUTH".to_string()]
1517 );
1518 let layer_path = dir.path().join("config-old.json");
1521 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1522 let cfg = load_layers(&[layer_path]).unwrap();
1523 assert!(cfg.contract_env_passthrough.is_empty());
1524 }
1525
1526 #[test]
1527 fn absent_auto_work_key_keeps_default() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let layer_path = dir.path().join("config.json");
1530 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1531
1532 let cfg = load_layers(&[layer_path]).unwrap();
1533 assert!(!cfg.auto_work);
1534 }
1535
1536 #[test]
1545 fn composition_audit_layered_deny_patterns_replace_but_never_strip_the_builtin_floor() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let global = dir.path().join("global.json");
1548 std::fs::write(&global, r#"{"denyPatterns": ["git push --force"]}"#).unwrap();
1549 let project = dir.path().join("project.json");
1550 std::fs::write(&project, r#"{"denyPatterns": ["rm -rf *"]}"#).unwrap();
1551
1552 let cfg = load_layers(&[global, project]).unwrap();
1553 assert_eq!(cfg.deny_patterns, vec!["rm -rf *".to_string()]);
1555
1556 let profile = crate::permissions::for_role(Role::Worker, &cfg, &[], &[], &[]);
1560 for builtin in [
1561 "Bash(git push*)",
1562 "Bash(sudo*)",
1563 "Bash(curl*)",
1564 "WebFetch",
1565 "WebSearch",
1566 ] {
1567 assert!(
1568 profile.disallowed_tools.iter().any(|r| r == builtin),
1569 "the built-in deny {builtin} must survive layered replacement"
1570 );
1571 }
1572 assert!(profile
1573 .disallowed_tools
1574 .iter()
1575 .any(|r| r == "Bash(rm -rf *)"));
1576 assert!(!profile
1577 .disallowed_tools
1578 .iter()
1579 .any(|r| r == "Bash(git push --force*)"));
1580 }
1581
1582 #[test]
1583 fn default_config_serializes_without_backend_field() {
1584 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1585 for role in [
1586 "orchestrator",
1587 "worker",
1588 "validatorScrutiny",
1589 "validatorFunctional",
1590 ] {
1591 let obj = value[role].as_object().unwrap();
1592 assert!(
1593 !obj.contains_key("backend"),
1594 "{role} should not serialize a backend key by default"
1595 );
1596 }
1597 }
1598
1599 #[test]
1600 fn validate_accepts_known_backends_for_each_role() {
1601 for role in [
1602 Role::Orchestrator,
1603 Role::Worker,
1604 Role::ValidatorScrutiny,
1605 Role::ValidatorFunctional,
1606 ] {
1607 for backend in [None, Some("claude"), Some("codex")] {
1608 let mut cfg = MissionConfig::default();
1609 cfg.role_mut_for_test(role).backend = backend.map(|s| s.to_string());
1610 assert!(
1611 validate(&cfg).is_ok(),
1612 "{role:?} backend {backend:?} should be accepted"
1613 );
1614 }
1615 }
1616 }
1617
1618 #[test]
1619 fn validate_accepts_droid_scrutiny_backend_with_legacy_default_model() {
1620 let mut cfg = MissionConfig::default();
1621 cfg.validator_scrutiny.backend = Some("droid".into());
1622 assert!(validate(&cfg).is_ok());
1623 assert_eq!(
1624 effective_model(
1625 Role::ValidatorScrutiny,
1626 BackendKind::Droid,
1627 &cfg.validator_scrutiny.model
1628 ),
1629 DEFAULT_DROID_MODEL
1630 );
1631 }
1632
1633 #[test]
1634 fn validate_rejects_unknown_backend_on_any_role() {
1635 for role in [
1636 Role::Orchestrator,
1637 Role::Worker,
1638 Role::ValidatorScrutiny,
1639 Role::ValidatorFunctional,
1640 ] {
1641 let mut cfg = MissionConfig::default();
1642 cfg.role_mut_for_test(role).backend = Some("gemini".into());
1643 assert!(validate(&cfg).is_err(), "{role:?} should reject gemini");
1644 }
1645 }
1646
1647 #[test]
1648 fn validate_rejects_unknown_backend_model_combos() {
1649 let mut cfg = MissionConfig::default();
1650 cfg.worker.model = "kranz-test-model".into();
1651 assert!(validate(&cfg).is_err());
1652
1653 let mut cfg = MissionConfig::default();
1654 cfg.validator_functional.backend = Some("codex".into());
1655 cfg.validator_functional.model = "claude-sonnet-5".into();
1656 assert!(validate(&cfg).is_err());
1657
1658 let mut cfg = MissionConfig::default();
1659 cfg.validator_scrutiny.backend = Some("droid".into());
1660 cfg.validator_scrutiny.model = "gpt-5-codex".into();
1661 assert!(validate(&cfg).is_err());
1662 }
1663
1664 #[test]
1665 fn validate_enforces_worker_floor_with_explicit_opt_in() {
1666 let mut cfg = MissionConfig::default();
1667 cfg.worker.model = "haiku".into();
1668 assert!(validate(&cfg).is_err());
1669 cfg.allow_below_default_worker_model = true;
1670 assert!(validate(&cfg).is_ok());
1671
1672 let mut cfg = MissionConfig::default();
1673 cfg.worker.backend = Some("droid".into());
1674 assert!(
1675 validate(&cfg).is_err(),
1676 "droid's legacy default GLM worker is below the default tier"
1677 );
1678 cfg.allow_below_default_worker_model = true;
1679 assert!(validate(&cfg).is_ok());
1680 }
1681
1682 #[test]
1683 fn floor_violations_lead_with_the_effective_model() {
1684 let mut cfg = MissionConfig::default();
1689 cfg.worker.backend = Some("droid".into());
1690 let configured = cfg.worker.model.clone();
1691 let err = validate(&cfg).unwrap_err().to_string();
1692 assert!(
1693 err.contains(&format!("worker effective model {DEFAULT_DROID_MODEL:?}")),
1694 "{err}"
1695 );
1696 assert!(
1697 err.contains(&format!("(configured as {configured:?})")),
1698 "{err}"
1699 );
1700
1701 let mut cfg = MissionConfig::default();
1702 cfg.orchestrator.backend = Some("droid".into());
1703 let configured = cfg.orchestrator.model.clone();
1704 let err = validate(&cfg).unwrap_err().to_string();
1705 assert!(
1706 err.contains(&format!(
1707 "orchestrator effective model {DEFAULT_DROID_MODEL:?}"
1708 )),
1709 "{err}"
1710 );
1711 assert!(
1712 err.contains(&format!("(configured as {configured:?})")),
1713 "{err}"
1714 );
1715 }
1716
1717 #[test]
1718 fn validate_enforces_orchestrator_frontier_floor() {
1719 let mut cfg = MissionConfig::default();
1720 cfg.orchestrator.model = "sonnet".into();
1721 assert!(validate(&cfg).is_err());
1722
1723 let mut cfg = MissionConfig::default();
1724 cfg.orchestrator.backend = Some("droid".into());
1725 assert!(
1726 validate(&cfg).is_err(),
1727 "droid's legacy default GLM model is not a planner frontier model"
1728 );
1729
1730 let mut cfg = MissionConfig::default();
1731 cfg.orchestrator.backend = Some("droid".into());
1732 cfg.orchestrator.model = "claude-fable-5".into();
1733 assert!(validate(&cfg).is_ok());
1734 }
1735
1736 #[test]
1737 fn validate_allows_scrutiny_on_any_supported_tier() {
1738 for (backend, model) in [
1739 (Some("claude"), "haiku"),
1740 (Some("claude"), "sonnet"),
1741 (Some("claude"), "opus"),
1742 (Some("codex"), DEFAULT_CODEX_MODEL),
1743 (Some("droid"), DEFAULT_DROID_MODEL),
1744 (Some("droid"), "claude-fable-5"),
1745 (Some("kimi"), DEFAULT_KIMI_MODEL),
1746 (Some("kimi"), "kimi-code/kimi-for-coding"),
1747 ] {
1748 let mut cfg = MissionConfig::default();
1749 cfg.validator_scrutiny.backend = backend.map(|s| s.to_string());
1750 cfg.validator_scrutiny.model = model.to_string();
1751 assert!(
1752 validate(&cfg).is_ok(),
1753 "scrutiny should accept {backend:?} / {model}"
1754 );
1755 }
1756 }
1757
1758 #[test]
1759 fn validate_accepts_kimi_k3_for_supported_efforts() {
1760 for effort in ["low", "high", "max"] {
1761 let mut cfg = MissionConfig::default();
1762 cfg.validator_scrutiny.backend = Some("kimi".into());
1763 cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1764 cfg.validator_scrutiny.reasoning_effort = effort.into();
1765 assert!(
1766 validate(&cfg).is_ok(),
1767 "kimi k3 should accept effort {effort}"
1768 );
1769 }
1770 }
1771
1772 #[test]
1773 fn guarded_local_validator_scrutiny_cannot_be_configured_local() {
1774 let mut cfg = MissionConfig::default();
1779 cfg.validator_scrutiny.backend = Some("local".into());
1780 cfg.validator_scrutiny.base_url = Some("http://127.0.0.1:8080".into());
1781 cfg.validator_scrutiny.context_budget = Some(8192);
1782 let err = validate(&cfg).unwrap_err().to_string();
1783 assert!(
1784 err.contains("validatorScrutiny.backend \"local\" is rejected"),
1785 "the rejection must name the role: {err}"
1786 );
1787
1788 let mut cfg = MissionConfig::default();
1789 cfg.validator_scrutiny.backend = Some("local".into());
1790 let err = validate(&cfg).unwrap_err().to_string();
1791 assert!(
1792 err.contains("validatorScrutiny.backend \"local\" is rejected"),
1793 "the role guard must fire before the endpoint checks: {err}"
1794 );
1795 }
1796
1797 #[test]
1798 fn guarded_local_validator_functional_may_be_configured_local() {
1799 let mut cfg = MissionConfig::default();
1804 cfg.validator_functional.backend = Some("local".into());
1805 cfg.validator_functional.base_url = Some("http://127.0.0.1:8080".into());
1806 cfg.validator_functional.context_budget = Some(8192);
1807 assert!(
1808 validate(&cfg).is_ok(),
1809 "functional + local with a valid endpoint must be accepted"
1810 );
1811
1812 let mut cfg = MissionConfig::default();
1815 cfg.validator_functional.backend = Some("local".into());
1816 let err = validate(&cfg).unwrap_err().to_string();
1817 assert!(
1818 err.contains("validatorFunctional.baseUrl is required"),
1819 "endpoint requirements must still apply to the functional role: {err}"
1820 );
1821 }
1822
1823 #[test]
1824 fn validate_rejects_kimi_k3_for_unsupported_efforts() {
1825 for effort in ["medium", "xhigh"] {
1826 let mut cfg = MissionConfig::default();
1827 cfg.validator_scrutiny.backend = Some("kimi".into());
1828 cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1829 cfg.validator_scrutiny.reasoning_effort = effort.into();
1830 let err = validate(&cfg).unwrap_err().to_string();
1831 assert!(
1832 err.contains("reasoningEffort"),
1833 "kimi k3 should reject effort {effort}: {err}"
1834 );
1835 }
1836 }
1837
1838 #[test]
1839 fn validate_kimi_for_coding_imposes_no_effort_constraint() {
1840 for effort in ["low", "medium", "high", "xhigh", "max"] {
1841 let mut cfg = MissionConfig::default();
1842 cfg.validator_scrutiny.backend = Some("kimi".into());
1843 cfg.validator_scrutiny.model = "kimi-code/kimi-for-coding".into();
1844 cfg.validator_scrutiny.reasoning_effort = effort.into();
1845 assert!(
1846 validate(&cfg).is_ok(),
1847 "kimi-for-coding should accept any effort, got {effort} err"
1848 );
1849 }
1850 }
1851
1852 #[test]
1853 fn validate_rejects_unsupported_kimi_model() {
1854 let mut cfg = MissionConfig::default();
1855 cfg.validator_scrutiny.backend = Some("kimi".into());
1856 cfg.validator_scrutiny.model = "kimi-unknown-model".into();
1857 assert!(validate(&cfg).is_err());
1858 }
1859
1860 #[test]
1861 fn sandbox_config_defaults_to_off() {
1862 let cfg = MissionConfig::default();
1863 for role in [
1864 &cfg.orchestrator,
1865 &cfg.worker,
1866 &cfg.validator_scrutiny,
1867 &cfg.validator_functional,
1868 ] {
1869 assert_eq!(role.sandbox.enforce, crate::types::SandboxEnforce::Off);
1870 assert!(role.sandbox.extra_write.is_empty());
1871 assert!(role.sandbox.egress.is_empty());
1872 }
1873 assert!(validate(&cfg).is_ok());
1874 }
1875
1876 #[test]
1877 fn sandbox_config_parses_fs() {
1878 let dir = tempfile::tempdir().unwrap();
1879 let layer_path = dir.path().join("config.json");
1880 std::fs::write(
1881 &layer_path,
1882 r#"{"worker":{"sandbox":{"enforce":"fs","extraWrite":["~/.cargo"]}}}"#,
1883 )
1884 .unwrap();
1885
1886 let cfg = load_layers(&[layer_path]).unwrap();
1887 assert_eq!(cfg.worker.sandbox.enforce, crate::types::SandboxEnforce::Fs);
1888 assert_eq!(cfg.worker.sandbox.extra_write, vec!["~/.cargo".to_string()]);
1889 assert_eq!(
1891 cfg.orchestrator.sandbox.enforce,
1892 crate::types::SandboxEnforce::Off
1893 );
1894 }
1895
1896 #[test]
1897 fn sandbox_config_extra_write_roundtrips() {
1898 let mut cfg = MissionConfig::default();
1899 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1900 cfg.worker.sandbox.extra_write = vec!["~/.cargo".into(), "~/.npm".into()];
1901 cfg.worker.sandbox.egress = vec!["registry.npmjs.org:443".into()];
1902
1903 let value = serde_json::to_value(&cfg).unwrap();
1904 assert_eq!(value["worker"]["sandbox"]["enforce"], "fs+net");
1905 assert_eq!(
1906 value["worker"]["sandbox"]["extraWrite"],
1907 serde_json::json!(["~/.cargo", "~/.npm"])
1908 );
1909 assert_eq!(
1910 value["worker"]["sandbox"]["egress"],
1911 serde_json::json!(["registry.npmjs.org:443"])
1912 );
1913
1914 let roundtripped: MissionConfig = serde_json::from_value(value).unwrap();
1915 assert_eq!(roundtripped, cfg);
1916 }
1917
1918 #[test]
1919 fn sandbox_config_parses_fs_plus_net() {
1920 let dir = tempfile::tempdir().unwrap();
1921 let layer_path = dir.path().join("config.json");
1922 std::fs::write(
1923 &layer_path,
1924 r#"{"worker":{"sandbox":{"enforce":"fs+net","egress":["crates.io:443"]}}}"#,
1925 )
1926 .unwrap();
1927
1928 let cfg = load_layers(&[layer_path]).unwrap();
1929 assert_eq!(
1930 cfg.worker.sandbox.enforce,
1931 crate::types::SandboxEnforce::FsNet
1932 );
1933 assert_eq!(cfg.worker.sandbox.egress, vec!["crates.io:443"]);
1934 }
1935
1936 #[test]
1937 fn only_claude_declares_sandbox_enforcement_support() {
1938 assert!(BackendKind::Claude.supports_sandbox_enforcement());
1939 for kind in [
1940 BackendKind::Codex,
1941 BackendKind::Droid,
1942 BackendKind::Kimi,
1943 BackendKind::Local,
1944 BackendKind::Cursor,
1945 ] {
1946 assert!(
1947 !kind.supports_sandbox_enforcement(),
1948 "{kind:?} must not claim sandbox enforcement support"
1949 );
1950 }
1951 }
1952
1953 #[test]
1954 fn validate_rejects_enforced_sandbox_on_non_claude_backends() {
1955 for backend in ["codex", "droid", "kimi", "cursor"] {
1956 for enforce in [
1957 crate::types::SandboxEnforce::Fs,
1958 crate::types::SandboxEnforce::FsNet,
1959 ] {
1960 let mut cfg = MissionConfig::default();
1961 cfg.validator_scrutiny.backend = Some(backend.into());
1962 cfg.validator_scrutiny.sandbox.enforce = enforce;
1963 let err = validate(&cfg).unwrap_err().to_string();
1964 assert!(err.contains("validatorScrutiny"), "{err}");
1967 assert!(err.contains(backend), "{err}");
1968 assert!(err.contains(enforce.as_str()), "{err}");
1969 assert!(err.contains("sandbox.enforce=off"), "{err}");
1970 assert!(err.contains("claude"), "{err}");
1971 }
1972 }
1973 }
1974
1975 #[test]
1976 fn validate_rejects_enforced_sandbox_on_codex_worker() {
1977 let mut cfg = MissionConfig::default();
1978 cfg.worker.backend = Some("codex".into());
1979 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1980 let err = validate(&cfg).unwrap_err().to_string();
1981 assert!(err.contains("worker.backend"), "{err}");
1982 assert!(err.contains("codex"), "{err}");
1983 assert!(err.contains("sandbox.enforce=off"), "{err}");
1984 }
1985
1986 #[test]
1987 fn validate_rejects_enforced_sandbox_on_local_backend() {
1988 let mut cfg = local_worker_cfg();
1991 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1992 let err = validate(&cfg).unwrap_err().to_string();
1993 assert!(err.contains("local"), "{err}");
1994 assert!(err.contains("fs+net"), "{err}");
1995 }
1996
1997 #[test]
1998 fn validate_rejects_container_provider_on_non_claude_backend() {
1999 let mut cfg = MissionConfig::default();
2002 cfg.validator_scrutiny.backend = Some("droid".into());
2003 cfg.validator_scrutiny.sandbox.enforce = crate::types::SandboxEnforce::Fs;
2004 cfg.validator_scrutiny.sandbox.provider = crate::types::SandboxProvider::Container;
2005 assert!(validate(&cfg).is_err());
2006 }
2007
2008 #[test]
2009 fn container_net_boundary_is_hard_only_for_process_or_empty_egress() {
2010 assert!(crate::types::SandboxProvider::Process
2014 .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
2015 assert!(crate::types::SandboxProvider::Container.enforces_hard_net_boundary(&[]));
2019 assert!(!crate::types::SandboxProvider::Container
2020 .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
2021 }
2022
2023 #[test]
2024 fn validate_accepts_container_fs_net_with_egress_list_for_runtime_resolution() {
2025 let mut cfg = MissionConfig::default();
2028 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2029 cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2030 cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2031 assert!(validate(&cfg).is_ok());
2032 }
2033
2034 #[test]
2035 fn validate_accepts_container_fs_and_container_fs_net_with_empty_egress() {
2036 for enforce in [
2040 crate::types::SandboxEnforce::Fs,
2041 crate::types::SandboxEnforce::FsNet,
2042 ] {
2043 let mut cfg = MissionConfig::default();
2044 cfg.worker.sandbox.enforce = enforce;
2045 cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2046 assert!(
2047 validate(&cfg).is_ok(),
2048 "container provider with sandbox.enforce={} and an empty egress list must validate",
2049 enforce.as_str()
2050 );
2051 }
2052 }
2053
2054 #[test]
2055 fn validate_accepts_process_fs_net_with_egress_list() {
2056 let mut cfg = MissionConfig::default();
2059 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2060 cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2061 assert!(
2062 validate(&cfg).is_ok(),
2063 "process provider fs+net with an egress list must still validate"
2064 );
2065 }
2066
2067 #[test]
2068 fn validate_accepts_enforced_sandbox_on_claude_backend() {
2069 for backend in [None, Some("claude")] {
2070 for enforce in [
2071 crate::types::SandboxEnforce::Fs,
2072 crate::types::SandboxEnforce::FsNet,
2073 ] {
2074 let mut cfg = MissionConfig::default();
2075 cfg.worker.backend = backend.map(|s| s.to_string());
2076 cfg.worker.sandbox.enforce = enforce;
2077 assert!(
2078 validate(&cfg).is_ok(),
2079 "claude worker with sandbox.enforce={} must validate",
2080 enforce.as_str()
2081 );
2082 }
2083 }
2084 }
2085
2086 #[test]
2087 fn validate_accepts_sandbox_off_on_every_backend() {
2088 for backend in ["codex", "droid", "kimi", "cursor"] {
2089 let mut cfg = MissionConfig::default();
2090 cfg.validator_scrutiny.backend = Some(backend.into());
2091 assert_eq!(
2092 cfg.validator_scrutiny.sandbox.enforce,
2093 crate::types::SandboxEnforce::Off
2094 );
2095 assert!(
2096 validate(&cfg).is_ok(),
2097 "{backend} with sandbox.enforce=off must validate"
2098 );
2099 }
2100 assert!(
2101 validate(&local_worker_cfg()).is_ok(),
2102 "local with sandbox.enforce=off must validate"
2103 );
2104 }
2105
2106 fn local_role_cfg() -> crate::types::RoleConfig {
2107 crate::types::RoleConfig {
2108 backend: Some("local".into()),
2109 model: "my-local-model".into(),
2110 base_url: Some("http://localhost:8080".into()),
2111 context_budget: Some(8192),
2112 ..MissionConfig::default().worker
2113 }
2114 }
2115
2116 fn local_worker_cfg() -> MissionConfig {
2117 MissionConfig {
2118 worker: local_role_cfg(),
2119 allow_below_default_worker_model: true,
2120 ..MissionConfig::default()
2121 }
2122 }
2123
2124 fn acp_worker_cfg() -> MissionConfig {
2127 let mut cfg = MissionConfig {
2128 allow_below_default_worker_model: true,
2129 ..MissionConfig::default()
2130 };
2131 cfg.worker.backend = Some("acp".into());
2132 cfg.worker.acp_command = Some("/opt/bin/my-acp-agent".into());
2133 cfg.worker.acp_args = vec!["--serve".into()];
2134 cfg
2135 }
2136
2137 #[test]
2138 fn backend_acp_config_round_trips_and_parses() {
2139 assert_eq!(parse_backend(Some("acp")), Ok(BackendKind::Acp));
2140 let cfg = acp_worker_cfg();
2141 assert_eq!(cfg.backend_kind(Role::Worker), BackendKind::Acp);
2142 assert_eq!(BackendKind::Acp.as_str(), "acp");
2143 assert!(!BackendKind::Acp.supports_sandbox_enforcement());
2144 assert!(!BackendKind::Acp.reports_cache_read_tokens());
2145 assert!(!BackendKind::Acp.reports_cache_write_tokens());
2146 assert!(
2147 validate(&cfg).is_ok(),
2148 "worker + acpCommand + the below-default opt-in must validate"
2149 );
2150 }
2151
2152 #[test]
2153 fn backend_acp_config_requires_worker_role_and_command() {
2154 for role in [
2157 Role::Orchestrator,
2158 Role::ValidatorScrutiny,
2159 Role::ValidatorFunctional,
2160 ] {
2161 let mut cfg = acp_worker_cfg();
2162 let role_cfg = match role {
2163 Role::Orchestrator => &mut cfg.orchestrator,
2164 Role::ValidatorScrutiny => &mut cfg.validator_scrutiny,
2165 Role::ValidatorFunctional => &mut cfg.validator_functional,
2166 Role::Worker => unreachable!("loop excludes the worker"),
2167 };
2168 role_cfg.backend = Some("acp".into());
2169 role_cfg.acp_command = Some("/opt/bin/my-acp-agent".into());
2170 let err = validate(&cfg).expect_err("non-worker acp must be refused");
2171 assert!(
2172 err.to_string().contains("worker role only"),
2173 "refusal must name the role restriction: {err}"
2174 );
2175 }
2176
2177 let mut cfg = acp_worker_cfg();
2179 cfg.worker.acp_command = None;
2180 let err = validate(&cfg).expect_err("missing acpCommand must be refused");
2181 assert!(err.to_string().contains("acpCommand"), "{err}");
2182 cfg.worker.acp_command = Some(" ".into());
2183 assert!(validate(&cfg).is_err(), "blank acpCommand must be refused");
2184
2185 let mut cfg = acp_worker_cfg();
2188 cfg.allow_below_default_worker_model = false;
2189 let err = validate(&cfg).expect_err("below-default acp worker needs the opt-in");
2190 assert!(
2191 err.to_string().contains("allowBelowDefaultWorkerModel"),
2192 "{err}"
2193 );
2194 }
2195
2196 #[test]
2197 fn local_config_requires_base_url() {
2198 let mut cfg = local_worker_cfg();
2199
2200 cfg.worker.base_url = None;
2201 assert!(
2202 validate(&cfg).is_err(),
2203 "missing baseUrl should be rejected"
2204 );
2205
2206 cfg.worker.base_url = Some("not a url".into());
2207 assert!(
2208 validate(&cfg).is_err(),
2209 "unparseable baseUrl should be rejected"
2210 );
2211
2212 cfg.worker.base_url = Some("http://localhost:8080".into());
2213 assert!(
2214 validate(&cfg).is_ok(),
2215 "valid http baseUrl should be accepted"
2216 );
2217
2218 cfg.worker.base_url = Some("https://models.internal/v1".into());
2223 assert!(
2224 validate(&cfg).is_err(),
2225 "a remote baseUrl needs the operator's allowlist"
2226 );
2227 cfg.local_backend_allowed_hosts = vec!["models.internal".into()];
2228 assert!(
2229 validate(&cfg).is_ok(),
2230 "valid https baseUrl at an allowlisted host should be accepted"
2231 );
2232 cfg.local_backend_allowed_hosts.clear();
2233
2234 cfg.worker.base_url = Some("http://127.0.0.1".into());
2235 assert!(
2236 validate(&cfg).is_ok(),
2237 "bare ip host baseUrl should be accepted"
2238 );
2239
2240 cfg.worker.base_url = Some("http://:8080".into());
2241 assert!(
2242 validate(&cfg).is_err(),
2243 "host-less authority with port should be rejected"
2244 );
2245
2246 cfg.worker.base_url = Some("http://@".into());
2247 assert!(
2248 validate(&cfg).is_err(),
2249 "userinfo-only authority should be rejected"
2250 );
2251
2252 cfg.worker.base_url = Some("http://@:8080".into());
2253 assert!(
2254 validate(&cfg).is_err(),
2255 "userinfo with port and no host should be rejected"
2256 );
2257 }
2258
2259 #[test]
2260 fn local_config_requires_context_budget_in_range() {
2261 let mut cfg = local_worker_cfg();
2262
2263 cfg.worker.context_budget = Some(1023);
2264 assert!(validate(&cfg).is_err(), "1023 is below the floor");
2265
2266 cfg.worker.context_budget = Some(200_001);
2267 assert!(validate(&cfg).is_err(), "200001 is above the ceiling");
2268
2269 cfg.worker.context_budget = None;
2270 assert!(validate(&cfg).is_err(), "missing contextBudget is rejected");
2271
2272 cfg.worker.context_budget = Some(8192);
2273 assert!(validate(&cfg).is_ok(), "8192 is in range");
2274 }
2275
2276 #[test]
2277 fn local_config_rejects_out_of_range_temperature() {
2278 let mut cfg = local_worker_cfg();
2279
2280 cfg.worker.temperature = Some(2.1);
2281 assert!(validate(&cfg).is_err(), "2.1 is above the ceiling");
2282
2283 cfg.worker.temperature = Some(-0.1);
2284 assert!(validate(&cfg).is_err(), "-0.1 is below the floor");
2285
2286 cfg.worker.temperature = Some(0.7);
2287 assert!(validate(&cfg).is_ok(), "0.7 is in range");
2288
2289 cfg.worker.temperature = None;
2290 assert!(validate(&cfg).is_ok(), "absent temperature is fine");
2291 }
2292
2293 #[test]
2294 fn local_config_worker_below_default_needs_optin() {
2295 let mut cfg = local_worker_cfg();
2296 cfg.allow_below_default_worker_model = false;
2297 assert!(
2298 validate(&cfg).is_err(),
2299 "local worker below-default tier requires opt-in"
2300 );
2301
2302 cfg.allow_below_default_worker_model = true;
2303 assert!(
2304 validate(&cfg).is_ok(),
2305 "local worker accepted once opted in"
2306 );
2307 }
2308
2309 #[test]
2310 fn local_config_orchestrator_local_always_rejected() {
2311 let cfg = MissionConfig {
2312 orchestrator: local_role_cfg(),
2313 allow_below_default_worker_model: true,
2314 ..MissionConfig::default()
2315 };
2316 assert!(
2317 validate(&cfg).is_err(),
2318 "local orchestrator always fails the frontier floor"
2319 );
2320 }
2321
2322 #[test]
2323 fn local_config_model_tier_below_default_for_any_nonempty() {
2324 assert_eq!(
2325 model_tier(BackendKind::Local, "any-model-id"),
2326 Some(ModelTier::BelowDefault)
2327 );
2328 assert_eq!(model_tier(BackendKind::Local, ""), None);
2329 assert_eq!(model_tier(BackendKind::Local, " "), None);
2330 }
2331
2332 #[test]
2333 fn local_config_effective_model_passes_through_verbatim_and_never_panics() {
2334 assert_eq!(
2335 effective_model(Role::Worker, BackendKind::Local, "my-local-model"),
2336 "my-local-model"
2337 );
2338 assert_eq!(
2341 effective_model(Role::Worker, BackendKind::Local, "sonnet"),
2342 "sonnet"
2343 );
2344 }
2345
2346 #[test]
2347 fn task_class_routing_maps_execution_class_to_local() {
2348 assert_eq!(
2349 task_class_to_tier(Some("execution-class")),
2350 ExecutorTier::Local
2351 );
2352 }
2353
2354 #[test]
2355 fn task_class_routing_defaults_to_frontier() {
2356 assert_eq!(
2357 task_class_to_tier(Some("planning-class")),
2358 ExecutorTier::Frontier
2359 );
2360 assert_eq!(
2361 task_class_to_tier(Some("some-arbitrary-value")),
2362 ExecutorTier::Frontier
2363 );
2364 assert_eq!(task_class_to_tier(None), ExecutorTier::Frontier);
2365 }
2366
2367 #[test]
2368 fn task_class_routing_is_case_and_whitespace_insensitive() {
2369 assert_eq!(
2370 task_class_to_tier(Some(" Execution-Class ")),
2371 ExecutorTier::Local
2372 );
2373 }
2374
2375 fn test_local_endpoint() -> LocalEndpoint {
2376 LocalEndpoint {
2377 base_url: "http://127.0.0.1:8080".to_string(),
2378 context_budget: 16_384,
2379 temperature: Some(0.2),
2380 }
2381 }
2382
2383 #[test]
2384 fn executor_routing_applies_local_backend_when_execution_class_and_endpoint_configured() {
2385 let mut cfg = MissionConfig::default();
2386 let validator_scrutiny_before = cfg.validator_scrutiny.clone();
2387 let validator_functional_before = cfg.validator_functional.clone();
2388 let endpoint = test_local_endpoint();
2389
2390 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2391
2392 assert_eq!(applied, ExecutorTier::Local);
2393 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2394 assert_eq!(
2395 cfg.worker.base_url.as_deref(),
2396 Some(endpoint.base_url.as_str())
2397 );
2398 assert_eq!(cfg.worker.context_budget, Some(endpoint.context_budget));
2399 assert_eq!(cfg.worker.temperature, endpoint.temperature);
2400 assert!(cfg.allow_below_default_worker_model);
2401 assert_eq!(cfg.validator_scrutiny, validator_scrutiny_before);
2402 assert_eq!(cfg.validator_functional, validator_functional_before);
2403 }
2404
2405 #[test]
2406 fn executor_routing_applies_fail_safe_frontier_when_no_endpoint_configured() {
2407 let mut cfg = MissionConfig::default();
2408 let worker_backend_before = cfg.worker.backend.clone();
2409
2410 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, None);
2411
2412 assert_eq!(applied, ExecutorTier::Frontier);
2413 assert_eq!(cfg.worker.backend, worker_backend_before);
2414 assert!(!cfg.allow_below_default_worker_model);
2415 }
2416
2417 #[test]
2418 fn executor_routing_applies_no_change_for_frontier_tier() {
2419 let mut cfg = MissionConfig::default();
2420 let before = cfg.clone();
2421 let endpoint = test_local_endpoint();
2422
2423 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Frontier, Some(&endpoint));
2424
2425 assert_eq!(applied, ExecutorTier::Frontier);
2426 assert_eq!(cfg, before);
2427 }
2428
2429 #[test]
2430 fn route_task_class_executor_routes_local_when_endpoint_configured() {
2431 let mut cfg = MissionConfig::default();
2435 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2436 cfg.worker.context_budget = Some(16_384);
2437
2438 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2439
2440 assert_eq!(applied, ExecutorTier::Local);
2441 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2442 assert_eq!(summary, "executor routed local (execution-class)");
2443 }
2444
2445 #[test]
2446 fn route_task_class_executor_stays_frontier_without_endpoint() {
2447 let mut cfg = MissionConfig::default();
2448 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2449
2450 assert_eq!(applied, ExecutorTier::Frontier);
2451 assert_eq!(cfg.worker.backend, None);
2452 assert!(summary.contains("no local endpoint configured"));
2453 }
2454
2455 #[test]
2456 fn route_task_class_executor_stays_frontier_for_non_execution_class() {
2457 let mut cfg = MissionConfig::default();
2458 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2459 cfg.worker.context_budget = Some(16_384);
2460
2461 let (applied, summary) = route_task_class_executor(&mut cfg, None);
2462
2463 assert_eq!(applied, ExecutorTier::Frontier);
2464 assert_eq!(cfg.worker.backend, None);
2465 assert_eq!(summary, "executor stays frontier");
2466 }
2467
2468 #[test]
2469 fn validator_stays_frontier_after_local_executor_routing() {
2470 let mut cfg = MissionConfig::default();
2471 let endpoint = test_local_endpoint();
2472
2473 apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2474
2475 assert_ne!(cfg.validator_scrutiny.backend.as_deref(), Some("local"));
2476 assert_ne!(cfg.validator_functional.backend.as_deref(), Some("local"));
2477 }
2478
2479 use crate::types::TaskClassRoute;
2484
2485 fn routing_table(rules: &[(&str, ExecutorTier)]) -> Vec<TaskClassRoute> {
2486 rules
2487 .iter()
2488 .map(|(task_class, tier)| TaskClassRoute {
2489 task_class: task_class.to_string(),
2490 tier: *tier,
2491 })
2492 .collect()
2493 }
2494
2495 #[test]
2496 fn routing_abstraction_table_defaults_empty_and_parses_camel_case() {
2497 assert!(MissionConfig::default().routing.task_class_rules.is_empty());
2501 let value = serde_json::to_value(MissionConfig::default()).unwrap();
2502 assert_eq!(value["routing"]["taskClassRules"], serde_json::json!([]));
2503
2504 let dir = tempfile::tempdir().unwrap();
2505 let layer_path = dir.path().join("config.json");
2506 std::fs::write(
2507 &layer_path,
2508 r#"{"routing": {"taskClassRules": [{"taskClass": "execution-class", "tier": "local"}, {"taskClass": "docs-class", "tier": "frontier"}]}}"#,
2509 )
2510 .unwrap();
2511 let cfg = load_layers(&[layer_path]).unwrap();
2512 assert_eq!(cfg.routing.task_class_rules.len(), 2);
2513 assert_eq!(
2514 cfg.routing.task_class_rules[0].task_class,
2515 "execution-class"
2516 );
2517 assert_eq!(cfg.routing.task_class_rules[0].tier, ExecutorTier::Local);
2518 assert_eq!(cfg.routing.task_class_rules[1].tier, ExecutorTier::Frontier);
2519
2520 let layer_path = dir.path().join("config-old.json");
2523 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2524 let cfg = load_layers(&[layer_path]).unwrap();
2525 assert!(cfg.routing.task_class_rules.is_empty());
2526 }
2527
2528 #[test]
2529 fn routing_abstraction_unconfigured_table_keeps_byte_identical_floor() {
2530 for task_class in [
2535 None,
2536 Some("execution-class"),
2537 Some(" Execution-Class "),
2538 Some("planning-class"),
2539 Some("some-arbitrary-value"),
2540 ] {
2541 for endpoint_configured in [false, true] {
2542 let wire = |cfg: &mut MissionConfig| {
2543 if endpoint_configured {
2544 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2545 cfg.worker.context_budget = Some(16_384);
2546 }
2547 };
2548 let mut cfg = MissionConfig::default();
2549 wire(&mut cfg);
2550 assert!(cfg.routing.task_class_rules.is_empty());
2551 let (applied, _) = route_task_class_executor(&mut cfg, task_class);
2552
2553 let mut reference = MissionConfig::default();
2555 wire(&mut reference);
2556 let endpoint = match (&reference.worker.base_url, reference.worker.context_budget) {
2557 (Some(base_url), Some(context_budget)) => Some(LocalEndpoint {
2558 base_url: base_url.clone(),
2559 context_budget,
2560 temperature: reference.worker.temperature,
2561 }),
2562 _ => None,
2563 };
2564 let expected = apply_executor_routing(
2565 &mut reference,
2566 task_class_to_tier(task_class),
2567 endpoint.as_ref(),
2568 );
2569
2570 assert_eq!(applied, expected, "task class {task_class:?}");
2571 assert_eq!(
2572 cfg, reference,
2573 "an empty table must apply byte-identical config changes for {task_class:?}"
2574 );
2575 }
2576 }
2577 }
2578
2579 #[test]
2580 fn routing_abstraction_table_routes_configured_class_to_local() {
2581 let mut cfg = MissionConfig::default();
2584 cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2585 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2586 cfg.worker.context_budget = Some(16_384);
2587
2588 let (applied, summary) = route_task_class_executor(&mut cfg, Some("docs-class"));
2589
2590 assert_eq!(applied, ExecutorTier::Local);
2591 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2592 assert_eq!(summary, "executor routed local (routing-table rule)");
2593
2594 let mut cfg = MissionConfig::default();
2598 cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2599 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2600 cfg.worker.context_budget = Some(16_384);
2601
2602 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2603
2604 assert_eq!(applied, ExecutorTier::Frontier);
2605 assert_eq!(cfg.worker.backend, None);
2606 assert_eq!(summary, "executor stays frontier");
2607 }
2608
2609 #[test]
2610 fn routing_abstraction_table_local_route_fails_safe_without_endpoint() {
2611 let mut cfg = MissionConfig::default();
2615 cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2616
2617 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2618
2619 assert_eq!(applied, ExecutorTier::Frontier);
2620 assert_eq!(cfg.worker.backend, None);
2621 assert!(
2622 summary.contains("no local endpoint configured"),
2623 "{summary}"
2624 );
2625 }
2626
2627 #[test]
2628 fn routing_abstraction_validate_fails_closed_on_malformed_table() {
2629 let mut cfg = MissionConfig::default();
2632 cfg.routing.task_class_rules = routing_table(&[
2633 ("execution-class", ExecutorTier::Local),
2634 (" Execution-Class", ExecutorTier::Frontier),
2635 ]);
2636 let err = validate(&cfg).unwrap_err().to_string();
2637 assert!(err.contains("routing.taskClassRules[1].taskClass"), "{err}");
2638 assert!(err.contains("duplicates rule 0"), "{err}");
2639
2640 let mut cfg = MissionConfig::default();
2642 cfg.routing.task_class_rules = routing_table(&[(" ", ExecutorTier::Local)]);
2643 let err = validate(&cfg).unwrap_err().to_string();
2644 assert!(err.contains("routing.taskClassRules[0].taskClass"), "{err}");
2645
2646 let mut cfg = MissionConfig::default();
2648 cfg.routing.task_class_rules = routing_table(&[
2649 ("execution-class", ExecutorTier::Local),
2650 ("docs-class", ExecutorTier::Frontier),
2651 ]);
2652 assert!(validate(&cfg).is_ok(), "a clean table must validate");
2653 }
2654
2655 #[test]
2656 fn routing_abstraction_hosted_fine_tune_is_plain_local_endpoint_config() {
2657 let mut cfg = local_worker_cfg();
2663 cfg.worker.base_url = Some("https://models.internal.example/v1".into());
2664 cfg.worker.model = "ft:some-model:some-org:some-id".into();
2665 cfg.local_backend_allowed_hosts = vec!["models.internal.example".into()];
2669 assert!(
2670 validate(&cfg).is_ok(),
2671 "a hosted fine-tune endpoint is ordinary local-backend config"
2672 );
2673
2674 cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2675 let (applied, _) = route_task_class_executor(&mut cfg, Some("execution-class"));
2676 assert_eq!(applied, ExecutorTier::Local);
2677 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2678 }
2679
2680 use crate::types::CandidateSpec;
2685
2686 fn dispatch_pool_pair() -> Vec<CandidateSpec> {
2687 vec![
2688 CandidateSpec {
2689 backend: "claude".into(),
2690 model: "sonnet".into(),
2691 },
2692 CandidateSpec {
2693 backend: "codex".into(),
2694 model: DEFAULT_CODEX_MODEL.into(),
2695 },
2696 ]
2697 }
2698
2699 #[test]
2700 fn dispatch_pool_defaults_empty_and_parses_camel_case() {
2701 assert!(MissionConfig::default().worker_candidates.is_empty());
2705 let value = serde_json::to_value(MissionConfig::default()).unwrap();
2706 assert_eq!(value["workerCandidates"], serde_json::json!([]));
2707
2708 let dir = tempfile::tempdir().unwrap();
2709 let layer_path = dir.path().join("config.json");
2710 std::fs::write(
2711 &layer_path,
2712 r#"{"workerCandidates": [{"backend": "claude", "model": "sonnet"}, {"backend": "codex", "model": "gpt-5.6-sol"}]}"#,
2713 )
2714 .unwrap();
2715 let cfg = load_layers(&[layer_path]).unwrap();
2716 assert_eq!(cfg.worker_candidates, dispatch_pool_pair());
2717
2718 let layer_path = dir.path().join("config-old.json");
2721 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2722 let cfg = load_layers(&[layer_path]).unwrap();
2723 assert!(cfg.worker_candidates.is_empty());
2724 }
2725
2726 #[test]
2727 fn dispatch_pool_validate_accepts_heterogeneous_pair() {
2728 let cfg = MissionConfig {
2729 worker_candidates: dispatch_pool_pair(),
2730 ..MissionConfig::default()
2731 };
2732 validate(&cfg).unwrap();
2733 }
2734
2735 #[test]
2736 fn dispatch_pool_validate_rejects_single_entry() {
2737 let cfg = MissionConfig {
2738 worker_candidates: vec![CandidateSpec {
2739 backend: "claude".into(),
2740 model: "sonnet".into(),
2741 }],
2742 ..MissionConfig::default()
2743 };
2744 let err = validate(&cfg).unwrap_err().to_string();
2745 assert!(
2746 err.contains("workerCandidates with exactly one entry"),
2747 "{err}"
2748 );
2749 }
2750
2751 #[test]
2752 fn dispatch_pool_validate_rejects_local_and_acp_candidates() {
2753 for backend in ["local", "acp"] {
2754 let cfg = MissionConfig {
2755 worker_candidates: vec![
2756 CandidateSpec {
2757 backend: "claude".into(),
2758 model: "sonnet".into(),
2759 },
2760 CandidateSpec {
2761 backend: backend.into(),
2762 model: "anything".into(),
2763 },
2764 ],
2765 ..MissionConfig::default()
2766 };
2767 let err = validate(&cfg).unwrap_err().to_string();
2768 assert!(
2769 err.contains("not supported in this pass"),
2770 "{backend}: {err}"
2771 );
2772 }
2773 }
2774
2775 #[test]
2776 fn dispatch_pool_validate_rejects_parallel_workers_combination() {
2777 let cfg = MissionConfig {
2778 worker_candidates: dispatch_pool_pair(),
2779 max_parallel_workers: 2,
2780 ..MissionConfig::default()
2781 };
2782 let err = validate(&cfg).unwrap_err().to_string();
2783 assert!(err.contains("mutually exclusive"), "{err}");
2784 }
2785
2786 #[test]
2787 fn dispatch_pool_validate_rejects_unknown_backend_and_model() {
2788 let cfg = MissionConfig {
2789 worker_candidates: vec![
2790 CandidateSpec {
2791 backend: "claude".into(),
2792 model: "sonnet".into(),
2793 },
2794 CandidateSpec {
2795 backend: "gemini".into(),
2796 model: "sonnet".into(),
2797 },
2798 ],
2799 ..MissionConfig::default()
2800 };
2801 let err = validate(&cfg).unwrap_err().to_string();
2802 assert!(err.contains("workerCandidates[1].backend"), "{err}");
2803
2804 let cfg = MissionConfig {
2805 worker_candidates: vec![
2806 CandidateSpec {
2807 backend: "claude".into(),
2808 model: "sonnet".into(),
2809 },
2810 CandidateSpec {
2811 backend: "codex".into(),
2812 model: "kranz-test-model".into(),
2813 },
2814 ],
2815 ..MissionConfig::default()
2816 };
2817 let err = validate(&cfg).unwrap_err().to_string();
2818 assert!(err.contains("not supported by backend"), "{err}");
2819 }
2820
2821 #[test]
2822 fn dispatch_pool_validate_enforces_worker_floor_per_candidate() {
2823 let mut cfg = MissionConfig {
2826 worker_candidates: vec![
2827 CandidateSpec {
2828 backend: "claude".into(),
2829 model: "sonnet".into(),
2830 },
2831 CandidateSpec {
2832 backend: "droid".into(),
2833 model: DEFAULT_DROID_MODEL.into(),
2834 },
2835 ],
2836 ..MissionConfig::default()
2837 };
2838 let err = validate(&cfg).unwrap_err().to_string();
2839 assert!(err.contains("below the default worker tier"), "{err}");
2840 cfg.allow_below_default_worker_model = true;
2841 validate(&cfg).unwrap();
2842 }
2843
2844 #[test]
2845 fn dispatch_pool_validate_rejects_sandboxed_non_claude_candidate() {
2846 let mut cfg = MissionConfig {
2847 worker_candidates: dispatch_pool_pair(),
2848 ..MissionConfig::default()
2849 };
2850 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
2851 let err = validate(&cfg).unwrap_err().to_string();
2852 assert!(err.contains("cannot honor sandbox.enforce"), "{err}");
2853 }
2854
2855 #[test]
2856 fn dispatch_pool_executor_routing_never_goes_local() {
2857 let mut cfg = MissionConfig {
2862 worker_candidates: dispatch_pool_pair(),
2863 ..MissionConfig::default()
2864 };
2865 let endpoint = test_local_endpoint();
2866 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2867 assert_eq!(applied, ExecutorTier::Frontier);
2868 assert!(cfg.worker.backend.is_none());
2869 }
2870
2871 trait RoleConfigTestExt {
2872 fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig;
2873 }
2874
2875 impl RoleConfigTestExt for MissionConfig {
2876 fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig {
2877 match role {
2878 Role::Orchestrator => &mut self.orchestrator,
2879 Role::Worker => &mut self.worker,
2880 Role::ValidatorScrutiny => &mut self.validator_scrutiny,
2881 Role::ValidatorFunctional => &mut self.validator_functional,
2882 }
2883 }
2884 }
2885}