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>.acpCommand",
472 PatchClass::Never,
473 "it names the ACP agent program",
474 ),
475 ConfigTrustRule::operator_only(
476 "<role>.acpArgs",
477 PatchClass::Never,
478 "it is argv for the ACP agent program",
479 ),
480 ConfigTrustRule::operator_only(
481 "<role>.baseUrl",
482 PatchClass::Never,
483 "the engine POSTs the assembled prompt to it from outside every sandbox",
484 ),
485 ConfigTrustRule::operator_only(
486 "<role>.sandbox.extraWrite",
487 PatchClass::Never,
488 "it widens the sandbox write allowlist",
489 ),
490 ConfigTrustRule::operator_only(
491 "<role>.sandbox.egress",
492 PatchClass::Never,
493 "it widens the sandbox egress allowlist",
494 ),
495 ConfigTrustRule::operator_only(
496 "<role>.sandbox.provider",
497 PatchClass::Never,
498 "it selects which containment mechanism wraps sessions",
499 ),
500 ConfigTrustRule::operator_only(
501 "<role>.sandbox.image",
502 PatchClass::Never,
503 "it names the container image sessions run inside",
504 ),
505 ConfigTrustRule {
506 key: "<role>.sandbox.enforce",
507 project: ProjectPolicy::SandboxFloor,
508 runtime: PatchClass::SandboxFloor,
509 reason: "a repository may raise sandbox enforcement, never lower it",
510 },
511 ConfigTrustRule {
512 key: "reviewerIndependence",
513 project: ProjectPolicy::ReviewerFloor,
514 runtime: PatchClass::Never,
515 reason: "a repository may strengthen reviewer independence, never weaken it",
516 },
517];
518
519fn config_trust_rule(normalized: &str) -> Option<&'static ConfigTrustRule> {
520 CONFIG_TRUST_RULES
521 .iter()
522 .find(|rule| path_matches(normalized, rule.key))
523}
524
525fn enforce_rank(value: Option<&serde_json::Value>) -> u8 {
529 use serde::Deserialize as _;
530 match value.and_then(|value| SandboxEnforce::deserialize(value).ok()) {
533 Some(SandboxEnforce::Fs) => 1,
534 Some(SandboxEnforce::FsNet) => 2,
535 Some(SandboxEnforce::Off) | None => 0,
536 }
537}
538
539fn project_layer_refusal(file: &Path, dotted: &str, reason: &str) -> EngineError {
540 EngineError::Config(format!(
541 "{}: the project config layer may not set {dotted:?} — {reason}. \
542 Operator-only keys are settable from the global layer \
543 (~/.kranz/config.json) only.",
544 file.display()
545 ))
546}
547
548pub fn check_project_layer_keys(
556 patch: &serde_json::Value,
557 base: &serde_json::Value,
558 file: &Path,
559) -> Result<()> {
560 if !patch.is_object() || !base.is_object() {
561 return Err(EngineError::Config(format!(
562 "{}: project config changes require JSON objects at the top level",
563 file.display()
564 )));
565 }
566 let mut trail: Vec<String> = Vec::new();
567 walk_project_layer(patch, Some(base), file, &mut trail)
568}
569
570fn check_project_reviewer_floor(
571 policy: &serde_json::Value,
572 base: Option<&serde_json::Value>,
573 file: &Path,
574 dotted: &str,
575 reason: &str,
576) -> Result<()> {
577 for role in ["scrutiny", "functional"] {
578 let required = base
579 .and_then(|policy| policy.get(role))
580 .and_then(serde_json::Value::as_bool)
581 == Some(true);
582 let retained = policy.is_object()
585 && policy
586 .get(role)
587 .is_none_or(|value| value.as_bool() == Some(true));
588 if required && !retained {
589 return Err(project_layer_refusal(
590 file,
591 &format!("{dotted}.{role}"),
592 reason,
593 ));
594 }
595 }
596 Ok(())
597}
598
599fn walk_project_layer(
600 patch: &serde_json::Value,
601 base: Option<&serde_json::Value>,
602 file: &Path,
603 trail: &mut Vec<String>,
604) -> Result<()> {
605 let serde_json::Value::Object(map) = patch else {
606 return Ok(());
607 };
608 for (key, value) in map {
609 trail.push(key.clone());
610 let dotted = trail.join(".");
611 let normalized = normalize_key_path(&dotted);
612
613 let base_value = base.and_then(|b| b.get(key));
614 let protected_container = CONFIG_TRUST_RULES.iter().any(|rule| {
618 (normalized != rule.key && path_matches(rule.key, &normalized))
619 || (normalized == rule.key && matches!(rule.project, ProjectPolicy::ReviewerFloor))
620 });
621 if protected_container {
622 if base_value.is_some_and(|base| !base.is_object()) {
623 return Err(EngineError::Config(format!(
624 "{}: cannot safely merge project config over non-object inherited field {dotted:?}; protected config containers must be JSON objects",
625 file.display()
626 )));
627 }
628 if !value.is_object() {
629 return Err(EngineError::Config(format!(
630 "{}: project config field {dotted:?} must be a JSON object; positional arrays and scalar replacements bypass protected child checks",
631 file.display()
632 )));
633 }
634 }
635 if let Some(rule) = config_trust_rule(&normalized) {
636 match rule.project {
637 ProjectPolicy::OperatorOnly => {
638 return Err(project_layer_refusal(file, &dotted, rule.reason));
639 }
640 ProjectPolicy::SandboxFloor if normalized == rule.key => {
641 if enforce_rank(Some(value)) < enforce_rank(base_value) {
642 return Err(project_layer_refusal(file, &dotted, rule.reason));
643 }
644 }
645 ProjectPolicy::ReviewerFloor if normalized == rule.key => {
646 check_project_reviewer_floor(value, base_value, file, &dotted, rule.reason)?;
647 }
648 _ => {}
649 }
650 }
651
652 walk_project_layer(value, base_value, file, trail)?;
653 trail.pop();
654 }
655 Ok(())
656}
657
658pub fn validate_claude_binary(cfg: &MissionConfig, repo_root: &Path) -> Result<()> {
668 let Some(raw) = cfg.claude_binary.as_deref() else {
669 return Ok(());
670 };
671 let trimmed = raw.trim();
672 if trimmed.is_empty() {
673 return Err(EngineError::Config(
674 "claudeBinary must not be empty; omit the key to auto-discover".into(),
675 ));
676 }
677 let candidate = Path::new(trimmed);
678 if !candidate.is_absolute() {
679 return Err(EngineError::Config(format!(
680 "claudeBinary {trimmed:?} must be an absolute path: a relative path resolves \
681 against the process working directory, so which program runs depends on where \
682 kranz was invoked"
683 )));
684 }
685 let candidate_forms = [
691 candidate.to_path_buf(),
692 std::fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf()),
693 ];
694 let root_forms = [
695 repo_root.to_path_buf(),
696 std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf()),
697 ];
698 for resolved in &candidate_forms {
699 for root in &root_forms {
700 if resolved.starts_with(root) {
701 return Err(EngineError::Config(format!(
702 "claudeBinary {trimmed:?} resolves inside the repository at {}: repository \
703 content must never name the binary kranz executes",
704 root.display()
705 )));
706 }
707 }
708 }
709 Ok(())
710}
711
712pub fn load(repo_root: &Path) -> Result<MissionConfig> {
718 let mut layers: Vec<(PathBuf, Layer)> = Vec::new();
719 if let Some(global) = paths::global_config() {
720 layers.push((global, Layer::Global));
721 }
722 layers.push((paths::project_config(repo_root), Layer::Project));
723 let cfg = load_layers_with_roles(&layers)?;
724 validate_claude_binary(&cfg, repo_root)?;
725 Ok(cfg)
726}
727
728pub fn load_layers(layers: &[PathBuf]) -> Result<MissionConfig> {
736 let with_roles: Vec<(PathBuf, Layer)> = layers
737 .iter()
738 .map(|path| (path.clone(), Layer::Global))
739 .collect();
740 load_layers_with_roles(&with_roles)
741}
742
743pub fn load_layers_with_roles(layers: &[(PathBuf, Layer)]) -> Result<MissionConfig> {
748 let mut merged = serde_json::to_value(MissionConfig::default())?;
749
750 for (path, layer) in layers {
751 let text = match std::fs::read_to_string(path) {
752 Ok(text) => text,
753 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
755 Err(e) => {
756 return Err(EngineError::Config(format!(
757 "cannot read config file {}: {e}",
758 path.display()
759 )))
760 }
761 };
762
763 let patch: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
764 EngineError::Config(format!(
765 "invalid JSON in config file {}: {e}",
766 path.display()
767 ))
768 })?;
769
770 if !patch.is_object() {
771 return Err(EngineError::Config(format!(
772 "config file {} must contain a JSON object at the top level",
773 path.display()
774 )));
775 }
776
777 if *layer == Layer::Project {
778 check_project_layer_keys(&patch, &merged, path)?;
779 }
780
781 deep_merge(&mut merged, &patch);
782 }
783
784 serde_json::from_value(merged)
785 .map_err(|e| EngineError::Config(format!("merged configuration does not deserialize: {e}")))
786}
787
788pub fn deep_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
793 match (base, patch) {
794 (serde_json::Value::Object(base_map), serde_json::Value::Object(patch_map)) => {
795 for (key, patch_val) in patch_map {
796 match base_map.get_mut(key) {
797 Some(slot) => deep_merge(slot, patch_val),
798 None => {
799 base_map.insert(key.clone(), patch_val.clone());
800 }
801 }
802 }
803 }
804 (slot, patch_val) => *slot = patch_val.clone(),
805 }
806}
807
808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
821pub enum PatchSource {
822 Operator,
825 Inbox,
827}
828
829const RUNTIME_PATCHABLE: &[&str] = &[
835 "maxFixCyclesPerMilestone",
836 "maxRespawns",
837 "maxParallelWorkers",
838 "eventStreamThrottleMs",
839 "planningIdleReleaseMinutes",
840 "autoWork",
841 "consideredAlternativesFeatureThreshold",
842 "consideredAlternativesTouchSetThreshold",
843 "consideredAlternativesHighUsdThreshold",
844 "rubberStampThresholdMs",
845 "<role>.model",
846 "<role>.backend",
847 "<role>.reasoningEffort",
848 "<role>.maxTurns",
849 "<role>.maxBudgetUsd",
850 "<role>.contextBudget",
851 "<role>.temperature",
852];
853
854fn classify_patch_key(normalized: &str) -> (PatchClass, &'static str) {
855 if let Some(rule) = config_trust_rule(normalized) {
856 let class = match rule.runtime {
859 PatchClass::SandboxFloor if normalized != rule.key => PatchClass::Never,
860 class => class,
861 };
862 return (class, rule.reason);
863 }
864 if RUNTIME_PATCHABLE
865 .iter()
866 .any(|key| path_matches(normalized, key))
867 {
868 return (PatchClass::Runtime, "");
869 }
870 (PatchClass::Never, "")
871}
872
873pub fn check_runtime_patch(
881 patch: &serde_json::Value,
882 base: &serde_json::Value,
883 source: PatchSource,
884) -> Result<()> {
885 let mut trail: Vec<String> = Vec::new();
886 walk_runtime_patch(patch, Some(base), source, &mut trail)
887}
888
889fn walk_runtime_patch(
890 patch: &serde_json::Value,
891 base: Option<&serde_json::Value>,
892 source: PatchSource,
893 trail: &mut Vec<String>,
894) -> Result<()> {
895 if let serde_json::Value::Object(map) = patch {
896 for (key, value) in map {
897 trail.push(key.clone());
898 walk_runtime_patch(value, base.and_then(|b| b.get(key)), source, trail)?;
899 trail.pop();
900 }
901 return Ok(());
902 }
903
904 let dotted = trail.join(".");
905 let normalized = normalize_key_path(&dotted);
906
907 let (class, reason) = classify_patch_key(&normalized);
908 match class {
909 PatchClass::SandboxFloor => {
910 if enforce_rank(Some(patch)) >= enforce_rank(base) {
911 return Ok(());
912 }
913 match source {
914 PatchSource::Operator => Ok(()),
915 PatchSource::Inbox => Err(EngineError::Config(format!(
916 "refusing a control-inbox config change to {dotted:?}: lowering sandbox \
917 enforcement is a consent act, and the control inbox is an \
918 unauthenticated filesystem channel"
919 ))),
920 }
921 }
922 PatchClass::Runtime => Ok(()),
923 PatchClass::Consent => match source {
924 PatchSource::Operator => Ok(()),
925 PatchSource::Inbox => Err(EngineError::Config(format!(
926 "refusing a control-inbox config change to {dotted:?}: {reason}, so it is a \
927 human decision — the control inbox is an unauthenticated filesystem \
928 channel and cannot carry consent"
929 ))),
930 },
931 PatchClass::Never => Err(EngineError::Config(format!(
932 "{dotted:?} is not runtime-patchable: it is seed-time or operator-file \
933 configuration (a program, an endpoint, a credential, or the containment \
934 shape), not a mission knob"
935 ))),
936 }
937}
938
939pub fn apply_validated_patch(
950 current: &MissionConfig,
951 patch: &serde_json::Value,
952) -> Result<MissionConfig> {
953 apply_validated_patch_from(current, patch, PatchSource::Operator)
954}
955
956pub fn apply_validated_patch_from(
960 current: &MissionConfig,
961 patch: &serde_json::Value,
962 source: PatchSource,
963) -> Result<MissionConfig> {
964 let mut value = serde_json::to_value(current)?;
965 check_runtime_patch(patch, &value, source)?;
966 deep_merge(&mut value, patch);
967 let merged: MissionConfig = serde_json::from_value(value)
968 .map_err(|e| EngineError::Config(format!("patch produces invalid config: {e}")))?;
969 validate(&merged)?;
970 Ok(merged)
971}
972
973fn base_url_host(url: &str) -> &str {
977 let Some(rest) = url
978 .strip_prefix("http://")
979 .or_else(|| url.strip_prefix("https://"))
980 else {
981 return "";
982 };
983 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
984 let after_userinfo = match authority.rfind('@') {
985 Some(idx) => &authority[idx + 1..],
986 None => authority,
987 };
988 if let Some(bracketed) = after_userinfo.strip_prefix('[') {
989 return match bracketed.split_once(']') {
990 Some((host, _)) => host,
991 None => "",
992 };
993 }
994 after_userinfo.split(':').next().unwrap_or(after_userinfo)
995}
996
997fn host_is_loopback(host: &str) -> bool {
999 host.eq_ignore_ascii_case("localhost")
1000 || host
1001 .parse::<std::net::IpAddr>()
1002 .is_ok_and(|ip| ip.is_loopback())
1003}
1004
1005pub fn validate(cfg: &MissionConfig) -> Result<()> {
1008 crate::reviewer_independence::validate_config(cfg)?;
1009 let roles = [
1010 ("orchestrator", &cfg.orchestrator),
1011 ("worker", &cfg.worker),
1012 ("validatorScrutiny", &cfg.validator_scrutiny),
1013 ("validatorFunctional", &cfg.validator_functional),
1014 ];
1015 for (name, role) in roles {
1016 if !VALID_EFFORTS.contains(&role.reasoning_effort.as_str()) {
1017 return Err(EngineError::Config(format!(
1018 "{name}.reasoningEffort must be one of {VALID_EFFORTS:?}, got {:?}",
1019 role.reasoning_effort
1020 )));
1021 }
1022 }
1023
1024 if cfg.max_fix_cycles_per_milestone < 1 {
1025 return Err(EngineError::Config(
1026 "maxFixCyclesPerMilestone must be at least 1".into(),
1027 ));
1028 }
1029
1030 if cfg.max_respawns > 5 {
1031 return Err(EngineError::Config(format!(
1032 "maxRespawns must be at most 5, got {}",
1033 cfg.max_respawns
1034 )));
1035 }
1036
1037 if !(10..=5000).contains(&cfg.event_stream_throttle_ms) {
1038 return Err(EngineError::Config(format!(
1039 "eventStreamThrottleMs must be in 10..=5000, got {}",
1040 cfg.event_stream_throttle_ms
1041 )));
1042 }
1043 if !cfg.considered_alternatives_high_usd_threshold.is_finite()
1044 || cfg.considered_alternatives_high_usd_threshold < 0.0
1045 {
1046 return Err(EngineError::Config(format!(
1047 "consideredAlternativesHighUsdThreshold must be finite and non-negative, got {}",
1048 cfg.considered_alternatives_high_usd_threshold
1049 )));
1050 }
1051
1052 if !(1..=8).contains(&cfg.max_parallel_workers) {
1059 return Err(EngineError::Config(format!(
1060 "maxParallelWorkers must be in 1..=8 (1 = sequential; >1 opts into M3 \
1061 parallel workers), got {}",
1062 cfg.max_parallel_workers
1063 )));
1064 }
1065
1066 if cfg.worker_candidates.len() == 1 {
1074 return Err(EngineError::Config(
1075 "workerCandidates with exactly one entry is a roundabout worker.backend; \
1076 use worker.backend (the pool exists for N >= 2 heterogeneous candidates)"
1077 .into(),
1078 ));
1079 }
1080 if cfg.worker_candidates.len() > MAX_WORKER_CANDIDATES {
1081 return Err(EngineError::Config(format!(
1082 "workerCandidates supports at most {MAX_WORKER_CANDIDATES} candidates, got {}",
1083 cfg.worker_candidates.len()
1084 )));
1085 }
1086 if !cfg.worker_candidates.is_empty() && cfg.max_parallel_workers > 1 {
1091 return Err(EngineError::Config(
1092 "workerCandidates (dispatch pool: one unit to N backends) and \
1093 maxParallelWorkers > 1 (M3: N independent units concurrently) are mutually \
1094 exclusive in this pass; configure one fan-out model"
1095 .into(),
1096 ));
1097 }
1098 for (i, candidate) in cfg.worker_candidates.iter().enumerate() {
1099 let kind = parse_backend(Some(&candidate.backend)).map_err(|other| {
1100 EngineError::Config(format!(
1101 "workerCandidates[{i}].backend must be one of \"claude\", \"codex\", \"droid\", \"kimi\", \"cursor\", got {other:?}"
1102 ))
1103 })?;
1104 if matches!(kind, BackendKind::Local | BackendKind::Acp) {
1108 return Err(EngineError::Config(format!(
1109 "workerCandidates[{i}].backend {:?} is not supported in this pass: local/acp \
1110 need per-candidate endpoint/command config (a deliberate widening); use \
1111 claude, codex, droid, kimi, or cursor candidates",
1112 candidate.backend
1113 )));
1114 }
1115 if cfg.worker.sandbox.enforce != SandboxEnforce::Off && !kind.supports_sandbox_enforcement()
1119 {
1120 return Err(EngineError::Config(format!(
1121 "workerCandidates[{i}].backend {:?} cannot honor sandbox.enforce={:?}: only the \
1122 claude backend applies the resolved OS sandbox; run with sandbox.enforce=off, \
1123 or drop the non-claude candidate",
1124 candidate.backend,
1125 cfg.worker.sandbox.enforce.as_str()
1126 )));
1127 }
1128 let effective = effective_model(Role::Worker, kind, &candidate.model);
1129 let tier = model_tier(kind, &effective).ok_or_else(|| {
1130 EngineError::Config(format!(
1131 "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1132 candidate.model,
1133 candidate.backend
1134 ))
1135 })?;
1136 if tier < ModelTier::Default && !cfg.allow_below_default_worker_model {
1139 return Err(EngineError::Config(format!(
1140 "workerCandidates[{i}] effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1141 allowBelowDefaultWorkerModel=true on this mission to opt in",
1142 candidate.model,
1143 candidate.backend
1144 )));
1145 }
1146 }
1147
1148 if let Err(err) = crate::routing::validate_table(&cfg.routing) {
1155 return Err(EngineError::Config(err));
1156 }
1157
1158 if let Some(hook_status) = &cfg.hook_status {
1164 if hook_status.enabled {
1165 if hook_status.endpoint.trim().is_empty() {
1166 return Err(EngineError::Config(
1167 "hookStatus.enabled requires hookStatus.endpoint (the loopback signal \
1168 POST URL, e.g. http://127.0.0.1:4560/api/hook-status)"
1169 .to_string(),
1170 ));
1171 }
1172 if !crate::hook_status::endpoint_is_loopback_http(&hook_status.endpoint) {
1173 return Err(EngineError::Config(format!(
1174 "hookStatus.endpoint must be a loopback http(s) URL (the per-run \
1175 capability token rides it), got {:?}",
1176 hook_status.endpoint
1177 )));
1178 }
1179 }
1180 }
1181
1182 for (role, name) in [
1183 (Role::Orchestrator, "orchestrator"),
1184 (Role::Worker, "worker"),
1185 (Role::ValidatorScrutiny, "validatorScrutiny"),
1186 (Role::ValidatorFunctional, "validatorFunctional"),
1187 ] {
1188 let role_cfg = cfg.role(role);
1189 let kind = parse_backend(role_cfg.backend.as_deref()).map_err(|other| {
1190 EngineError::Config(format!(
1191 "{name}.backend must be one of None, \"claude\", \"codex\", \"droid\", \"kimi\", \"local\", \"acp\", \"cursor\", got {other:?}"
1192 ))
1193 })?;
1194 if role_cfg.sandbox.enforce != SandboxEnforce::Off && !kind.supports_sandbox_enforcement() {
1201 return Err(EngineError::Config(format!(
1202 "{name}.backend {:?} cannot honor sandbox.enforce={:?}: only the claude backend \
1203 applies the resolved OS sandbox; run with sandbox.enforce=off to proceed \
1204 unsandboxed, or use the claude backend",
1205 kind.as_str(),
1206 role_cfg.sandbox.enforce.as_str()
1207 )));
1208 }
1209 let effective = effective_model(role, kind, &role_cfg.model);
1210 let tier = model_tier(kind, &effective).ok_or_else(|| {
1211 EngineError::Config(format!(
1212 "{name} effective model {effective:?} (configured as {:?}) is not supported by backend {:?}",
1213 role_cfg.model,
1214 kind.as_str()
1215 ))
1216 })?;
1217
1218 if kind == BackendKind::Local {
1219 if role == Role::ValidatorScrutiny {
1236 return Err(EngineError::Config(format!(
1237 "{name}.backend \"local\" is rejected: scrutiny is judgment, not a \
1238 deterministic mechanical check, and the local validator tier is the \
1239 functional role only (KRZ-206b) — a local judgment PASS is the \
1240 silent-green failure mode the guarded role split exists to prevent"
1241 )));
1242 }
1243 match role_cfg.base_url.as_deref() {
1244 Some(url) if !url.trim().is_empty() => {
1245 if base_url_host(url).is_empty() {
1246 return Err(EngineError::Config(format!(
1247 "{name}.baseUrl {url:?} is not a valid http/https URL"
1248 )));
1249 }
1250 let host = base_url_host(url);
1261 if !host_is_loopback(host)
1262 && !cfg
1263 .local_backend_allowed_hosts
1264 .iter()
1265 .any(|allowed| allowed.trim().eq_ignore_ascii_case(host))
1266 {
1267 return Err(EngineError::Config(format!(
1268 "{name}.baseUrl host {host:?} is not loopback: the engine POSTs \
1269 the assembled prompt to it from outside every sandbox and takes \
1270 the reply as model output. Use a loopback endpoint, or name the \
1271 host in localBackendAllowedHosts in ~/.kranz/config.json (the \
1272 global layer only)"
1273 )));
1274 }
1275 }
1276 _ => {
1277 return Err(EngineError::Config(format!(
1278 "{name}.baseUrl is required when {name}.backend is \"local\""
1279 )));
1280 }
1281 }
1282
1283 match role_cfg.context_budget {
1284 Some(budget) if (1024..=200_000).contains(&budget) => {}
1285 Some(budget) => {
1286 return Err(EngineError::Config(format!(
1287 "{name}.contextBudget must be in 1024..=200000, got {budget}"
1288 )));
1289 }
1290 None => {
1291 return Err(EngineError::Config(format!(
1292 "{name}.contextBudget is required when {name}.backend is \"local\""
1293 )));
1294 }
1295 }
1296
1297 if let Some(temperature) = role_cfg.temperature {
1298 if !temperature.is_finite() || !(0.0..=2.0).contains(&temperature) {
1299 return Err(EngineError::Config(format!(
1300 "{name}.temperature must be finite and in 0.0..=2.0, got {temperature}"
1301 )));
1302 }
1303 }
1304 }
1305
1306 if kind == BackendKind::Acp {
1307 if role != Role::Worker {
1313 return Err(EngineError::Config(format!(
1314 "{name}.backend \"acp\" is supported for the worker role only in this pass \
1315 (KRZ-301); validators and the orchestrator stay on their existing backends"
1316 )));
1317 }
1318 match role_cfg.acp_command.as_deref() {
1319 Some(command) if !command.trim().is_empty() => {}
1320 _ => {
1321 return Err(EngineError::Config(format!(
1322 "{name}.acpCommand is required when {name}.backend is \"acp\" \
1323 (the ACP agent executable; extra argv goes in {name}.acpArgs)"
1324 )));
1325 }
1326 }
1327 }
1328
1329 if kind == BackendKind::Kimi
1334 && effective == DEFAULT_KIMI_MODEL
1335 && !["low", "high", "max"].contains(&role_cfg.reasoning_effort.as_str())
1336 {
1337 return Err(EngineError::Config(format!(
1338 "{name}.reasoningEffort must be one of [\"low\", \"high\", \"max\"] for kimi model {DEFAULT_KIMI_MODEL:?}, got {:?}",
1339 role_cfg.reasoning_effort
1340 )));
1341 }
1342
1343 if role == Role::Worker
1344 && tier < ModelTier::Default
1345 && !cfg.allow_below_default_worker_model
1346 {
1347 return Err(EngineError::Config(format!(
1348 "worker effective model {effective:?} (configured as {:?}) on backend {:?} is below the default worker tier; set \
1349 allowBelowDefaultWorkerModel=true on this mission to opt in",
1350 role_cfg.model,
1351 kind.as_str()
1352 )));
1353 }
1354
1355 if role == Role::Orchestrator && tier < ModelTier::Frontier {
1356 return Err(EngineError::Config(format!(
1357 "orchestrator effective model {effective:?} (configured as {:?}) on backend {:?} is below the frontier-model floor",
1358 role_cfg.model,
1359 kind.as_str()
1360 )));
1361 }
1362 }
1363
1364 Ok(())
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370
1371 #[test]
1372 fn default_planning_idle_release_minutes_is_30() {
1373 assert_eq!(MissionConfig::default().planning_idle_release_minutes, 30);
1374 }
1375
1376 #[test]
1377 fn default_serializes_camel_case_planning_idle_release_minutes() {
1378 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1379 assert_eq!(value["planningIdleReleaseMinutes"], 30);
1380 }
1381
1382 #[test]
1383 fn layer_overrides_planning_idle_release_minutes() {
1384 let dir = tempfile::tempdir().unwrap();
1385 let layer_path = dir.path().join("config.json");
1386 std::fs::write(&layer_path, r#"{"planningIdleReleaseMinutes": 5}"#).unwrap();
1387
1388 let cfg = load_layers(&[layer_path]).unwrap();
1389 assert_eq!(cfg.planning_idle_release_minutes, 5);
1390 }
1391
1392 #[test]
1393 fn absent_key_in_layer_keeps_default() {
1394 let dir = tempfile::tempdir().unwrap();
1395 let layer_path = dir.path().join("config.json");
1396 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1397
1398 let cfg = load_layers(&[layer_path]).unwrap();
1399 assert_eq!(cfg.planning_idle_release_minutes, 30);
1400 }
1401
1402 #[test]
1403 fn default_auto_work_is_false() {
1404 assert!(!MissionConfig::default().auto_work);
1405 }
1406
1407 #[test]
1408 fn default_serializes_camel_case_auto_work() {
1409 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1410 assert_eq!(value["autoWork"], false);
1411 }
1412
1413 #[test]
1414 fn default_serializes_camel_case_considered_alternatives_thresholds() {
1415 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1416 assert_eq!(value["consideredAlternativesFeatureThreshold"], 4);
1417 assert_eq!(value["consideredAlternativesTouchSetThreshold"], 4);
1418 assert_eq!(value["consideredAlternativesHighUsdThreshold"], 0.0);
1419 }
1420
1421 #[test]
1422 fn layer_overrides_considered_alternatives_thresholds() {
1423 let dir = tempfile::tempdir().unwrap();
1424 let layer_path = dir.path().join("config.json");
1425 std::fs::write(
1426 &layer_path,
1427 r#"{
1428 "consideredAlternativesFeatureThreshold": 2,
1429 "consideredAlternativesTouchSetThreshold": 3,
1430 "consideredAlternativesHighUsdThreshold": 9.5
1431 }"#,
1432 )
1433 .unwrap();
1434
1435 let cfg = load_layers(&[layer_path]).unwrap();
1436 assert_eq!(cfg.considered_alternatives_feature_threshold, 2);
1437 assert_eq!(cfg.considered_alternatives_touch_set_threshold, 3);
1438 assert_eq!(cfg.considered_alternatives_high_usd_threshold, 9.5);
1439 }
1440
1441 #[test]
1442 fn default_serializes_camel_case_worker_floor_opt_in() {
1443 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1444 assert_eq!(value["allowBelowDefaultWorkerModel"], false);
1445 }
1446
1447 #[test]
1448 fn layer_overrides_auto_work() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let layer_path = dir.path().join("config.json");
1451 std::fs::write(&layer_path, r#"{"autoWork": true}"#).unwrap();
1452
1453 let cfg = load_layers(&[layer_path]).unwrap();
1454 assert!(cfg.auto_work);
1455 }
1456
1457 #[test]
1463 fn validator_allow_uncontained_degrade_defaults_off_and_parses_opt_in() {
1464 assert!(!MissionConfig::default().validator_allow_uncontained_degrade);
1465 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1466 assert_eq!(value["validatorAllowUncontainedDegrade"], false);
1467
1468 let dir = tempfile::tempdir().unwrap();
1469 let layer_path = dir.path().join("config.json");
1470 std::fs::write(&layer_path, r#"{"validatorAllowUncontainedDegrade": true}"#).unwrap();
1471 let cfg = load_layers(&[layer_path]).unwrap();
1472 assert!(cfg.validator_allow_uncontained_degrade);
1473
1474 let layer_path = dir.path().join("config-old.json");
1477 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1478 let cfg = load_layers(&[layer_path]).unwrap();
1479 assert!(!cfg.validator_allow_uncontained_degrade);
1480 }
1481
1482 #[test]
1483 fn contract_env_passthrough_defaults_empty_and_parses_camel_case() {
1484 assert!(MissionConfig::default().contract_env_passthrough.is_empty());
1487 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1488 assert_eq!(value["contractEnvPassthrough"], serde_json::json!([]));
1489
1490 let dir = tempfile::tempdir().unwrap();
1491 let layer_path = dir.path().join("config.json");
1492 std::fs::write(
1493 &layer_path,
1494 r#"{"contractEnvPassthrough": ["NPM_TOKEN", "REGISTRY_BASIC_AUTH"]}"#,
1495 )
1496 .unwrap();
1497 let cfg = load_layers(&[layer_path]).unwrap();
1498 assert_eq!(
1499 cfg.contract_env_passthrough,
1500 vec!["NPM_TOKEN".to_string(), "REGISTRY_BASIC_AUTH".to_string()]
1501 );
1502 let layer_path = dir.path().join("config-old.json");
1505 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1506 let cfg = load_layers(&[layer_path]).unwrap();
1507 assert!(cfg.contract_env_passthrough.is_empty());
1508 }
1509
1510 #[test]
1511 fn absent_auto_work_key_keeps_default() {
1512 let dir = tempfile::tempdir().unwrap();
1513 let layer_path = dir.path().join("config.json");
1514 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
1515
1516 let cfg = load_layers(&[layer_path]).unwrap();
1517 assert!(!cfg.auto_work);
1518 }
1519
1520 #[test]
1529 fn composition_audit_layered_deny_patterns_replace_but_never_strip_the_builtin_floor() {
1530 let dir = tempfile::tempdir().unwrap();
1531 let global = dir.path().join("global.json");
1532 std::fs::write(&global, r#"{"denyPatterns": ["git push --force"]}"#).unwrap();
1533 let project = dir.path().join("project.json");
1534 std::fs::write(&project, r#"{"denyPatterns": ["rm -rf *"]}"#).unwrap();
1535
1536 let cfg = load_layers(&[global, project]).unwrap();
1537 assert_eq!(cfg.deny_patterns, vec!["rm -rf *".to_string()]);
1539
1540 let profile = crate::permissions::for_role(Role::Worker, &cfg, &[], &[], &[]);
1544 for builtin in [
1545 "Bash(git push*)",
1546 "Bash(sudo*)",
1547 "Bash(curl*)",
1548 "WebFetch",
1549 "WebSearch",
1550 ] {
1551 assert!(
1552 profile.disallowed_tools.iter().any(|r| r == builtin),
1553 "the built-in deny {builtin} must survive layered replacement"
1554 );
1555 }
1556 assert!(profile
1557 .disallowed_tools
1558 .iter()
1559 .any(|r| r == "Bash(rm -rf *)"));
1560 assert!(!profile
1561 .disallowed_tools
1562 .iter()
1563 .any(|r| r == "Bash(git push --force*)"));
1564 }
1565
1566 #[test]
1567 fn default_config_serializes_without_backend_field() {
1568 let value = serde_json::to_value(MissionConfig::default()).unwrap();
1569 for role in [
1570 "orchestrator",
1571 "worker",
1572 "validatorScrutiny",
1573 "validatorFunctional",
1574 ] {
1575 let obj = value[role].as_object().unwrap();
1576 assert!(
1577 !obj.contains_key("backend"),
1578 "{role} should not serialize a backend key by default"
1579 );
1580 }
1581 }
1582
1583 #[test]
1584 fn validate_accepts_known_backends_for_each_role() {
1585 for role in [
1586 Role::Orchestrator,
1587 Role::Worker,
1588 Role::ValidatorScrutiny,
1589 Role::ValidatorFunctional,
1590 ] {
1591 for backend in [None, Some("claude"), Some("codex")] {
1592 let mut cfg = MissionConfig::default();
1593 cfg.role_mut_for_test(role).backend = backend.map(|s| s.to_string());
1594 assert!(
1595 validate(&cfg).is_ok(),
1596 "{role:?} backend {backend:?} should be accepted"
1597 );
1598 }
1599 }
1600 }
1601
1602 #[test]
1603 fn validate_accepts_droid_scrutiny_backend_with_legacy_default_model() {
1604 let mut cfg = MissionConfig::default();
1605 cfg.validator_scrutiny.backend = Some("droid".into());
1606 assert!(validate(&cfg).is_ok());
1607 assert_eq!(
1608 effective_model(
1609 Role::ValidatorScrutiny,
1610 BackendKind::Droid,
1611 &cfg.validator_scrutiny.model
1612 ),
1613 DEFAULT_DROID_MODEL
1614 );
1615 }
1616
1617 #[test]
1618 fn validate_rejects_unknown_backend_on_any_role() {
1619 for role in [
1620 Role::Orchestrator,
1621 Role::Worker,
1622 Role::ValidatorScrutiny,
1623 Role::ValidatorFunctional,
1624 ] {
1625 let mut cfg = MissionConfig::default();
1626 cfg.role_mut_for_test(role).backend = Some("gemini".into());
1627 assert!(validate(&cfg).is_err(), "{role:?} should reject gemini");
1628 }
1629 }
1630
1631 #[test]
1632 fn validate_rejects_unknown_backend_model_combos() {
1633 let mut cfg = MissionConfig::default();
1634 cfg.worker.model = "kranz-test-model".into();
1635 assert!(validate(&cfg).is_err());
1636
1637 let mut cfg = MissionConfig::default();
1638 cfg.validator_functional.backend = Some("codex".into());
1639 cfg.validator_functional.model = "claude-sonnet-5".into();
1640 assert!(validate(&cfg).is_err());
1641
1642 let mut cfg = MissionConfig::default();
1643 cfg.validator_scrutiny.backend = Some("droid".into());
1644 cfg.validator_scrutiny.model = "gpt-5-codex".into();
1645 assert!(validate(&cfg).is_err());
1646 }
1647
1648 #[test]
1649 fn validate_enforces_worker_floor_with_explicit_opt_in() {
1650 let mut cfg = MissionConfig::default();
1651 cfg.worker.model = "haiku".into();
1652 assert!(validate(&cfg).is_err());
1653 cfg.allow_below_default_worker_model = true;
1654 assert!(validate(&cfg).is_ok());
1655
1656 let mut cfg = MissionConfig::default();
1657 cfg.worker.backend = Some("droid".into());
1658 assert!(
1659 validate(&cfg).is_err(),
1660 "droid's legacy default GLM worker is below the default tier"
1661 );
1662 cfg.allow_below_default_worker_model = true;
1663 assert!(validate(&cfg).is_ok());
1664 }
1665
1666 #[test]
1667 fn floor_violations_lead_with_the_effective_model() {
1668 let mut cfg = MissionConfig::default();
1673 cfg.worker.backend = Some("droid".into());
1674 let configured = cfg.worker.model.clone();
1675 let err = validate(&cfg).unwrap_err().to_string();
1676 assert!(
1677 err.contains(&format!("worker effective model {DEFAULT_DROID_MODEL:?}")),
1678 "{err}"
1679 );
1680 assert!(
1681 err.contains(&format!("(configured as {configured:?})")),
1682 "{err}"
1683 );
1684
1685 let mut cfg = MissionConfig::default();
1686 cfg.orchestrator.backend = Some("droid".into());
1687 let configured = cfg.orchestrator.model.clone();
1688 let err = validate(&cfg).unwrap_err().to_string();
1689 assert!(
1690 err.contains(&format!(
1691 "orchestrator effective model {DEFAULT_DROID_MODEL:?}"
1692 )),
1693 "{err}"
1694 );
1695 assert!(
1696 err.contains(&format!("(configured as {configured:?})")),
1697 "{err}"
1698 );
1699 }
1700
1701 #[test]
1702 fn validate_enforces_orchestrator_frontier_floor() {
1703 let mut cfg = MissionConfig::default();
1704 cfg.orchestrator.model = "sonnet".into();
1705 assert!(validate(&cfg).is_err());
1706
1707 let mut cfg = MissionConfig::default();
1708 cfg.orchestrator.backend = Some("droid".into());
1709 assert!(
1710 validate(&cfg).is_err(),
1711 "droid's legacy default GLM model is not a planner frontier model"
1712 );
1713
1714 let mut cfg = MissionConfig::default();
1715 cfg.orchestrator.backend = Some("droid".into());
1716 cfg.orchestrator.model = "claude-fable-5".into();
1717 assert!(validate(&cfg).is_ok());
1718 }
1719
1720 #[test]
1721 fn validate_allows_scrutiny_on_any_supported_tier() {
1722 for (backend, model) in [
1723 (Some("claude"), "haiku"),
1724 (Some("claude"), "sonnet"),
1725 (Some("claude"), "opus"),
1726 (Some("codex"), DEFAULT_CODEX_MODEL),
1727 (Some("droid"), DEFAULT_DROID_MODEL),
1728 (Some("droid"), "claude-fable-5"),
1729 (Some("kimi"), DEFAULT_KIMI_MODEL),
1730 (Some("kimi"), "kimi-code/kimi-for-coding"),
1731 ] {
1732 let mut cfg = MissionConfig::default();
1733 cfg.validator_scrutiny.backend = backend.map(|s| s.to_string());
1734 cfg.validator_scrutiny.model = model.to_string();
1735 assert!(
1736 validate(&cfg).is_ok(),
1737 "scrutiny should accept {backend:?} / {model}"
1738 );
1739 }
1740 }
1741
1742 #[test]
1743 fn validate_accepts_kimi_k3_for_supported_efforts() {
1744 for effort in ["low", "high", "max"] {
1745 let mut cfg = MissionConfig::default();
1746 cfg.validator_scrutiny.backend = Some("kimi".into());
1747 cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1748 cfg.validator_scrutiny.reasoning_effort = effort.into();
1749 assert!(
1750 validate(&cfg).is_ok(),
1751 "kimi k3 should accept effort {effort}"
1752 );
1753 }
1754 }
1755
1756 #[test]
1757 fn guarded_local_validator_scrutiny_cannot_be_configured_local() {
1758 let mut cfg = MissionConfig::default();
1763 cfg.validator_scrutiny.backend = Some("local".into());
1764 cfg.validator_scrutiny.base_url = Some("http://127.0.0.1:8080".into());
1765 cfg.validator_scrutiny.context_budget = Some(8192);
1766 let err = validate(&cfg).unwrap_err().to_string();
1767 assert!(
1768 err.contains("validatorScrutiny.backend \"local\" is rejected"),
1769 "the rejection must name the role: {err}"
1770 );
1771
1772 let mut cfg = MissionConfig::default();
1773 cfg.validator_scrutiny.backend = Some("local".into());
1774 let err = validate(&cfg).unwrap_err().to_string();
1775 assert!(
1776 err.contains("validatorScrutiny.backend \"local\" is rejected"),
1777 "the role guard must fire before the endpoint checks: {err}"
1778 );
1779 }
1780
1781 #[test]
1782 fn guarded_local_validator_functional_may_be_configured_local() {
1783 let mut cfg = MissionConfig::default();
1788 cfg.validator_functional.backend = Some("local".into());
1789 cfg.validator_functional.base_url = Some("http://127.0.0.1:8080".into());
1790 cfg.validator_functional.context_budget = Some(8192);
1791 assert!(
1792 validate(&cfg).is_ok(),
1793 "functional + local with a valid endpoint must be accepted"
1794 );
1795
1796 let mut cfg = MissionConfig::default();
1799 cfg.validator_functional.backend = Some("local".into());
1800 let err = validate(&cfg).unwrap_err().to_string();
1801 assert!(
1802 err.contains("validatorFunctional.baseUrl is required"),
1803 "endpoint requirements must still apply to the functional role: {err}"
1804 );
1805 }
1806
1807 #[test]
1808 fn validate_rejects_kimi_k3_for_unsupported_efforts() {
1809 for effort in ["medium", "xhigh"] {
1810 let mut cfg = MissionConfig::default();
1811 cfg.validator_scrutiny.backend = Some("kimi".into());
1812 cfg.validator_scrutiny.model = DEFAULT_KIMI_MODEL.into();
1813 cfg.validator_scrutiny.reasoning_effort = effort.into();
1814 let err = validate(&cfg).unwrap_err().to_string();
1815 assert!(
1816 err.contains("reasoningEffort"),
1817 "kimi k3 should reject effort {effort}: {err}"
1818 );
1819 }
1820 }
1821
1822 #[test]
1823 fn validate_kimi_for_coding_imposes_no_effort_constraint() {
1824 for effort in ["low", "medium", "high", "xhigh", "max"] {
1825 let mut cfg = MissionConfig::default();
1826 cfg.validator_scrutiny.backend = Some("kimi".into());
1827 cfg.validator_scrutiny.model = "kimi-code/kimi-for-coding".into();
1828 cfg.validator_scrutiny.reasoning_effort = effort.into();
1829 assert!(
1830 validate(&cfg).is_ok(),
1831 "kimi-for-coding should accept any effort, got {effort} err"
1832 );
1833 }
1834 }
1835
1836 #[test]
1837 fn validate_rejects_unsupported_kimi_model() {
1838 let mut cfg = MissionConfig::default();
1839 cfg.validator_scrutiny.backend = Some("kimi".into());
1840 cfg.validator_scrutiny.model = "kimi-unknown-model".into();
1841 assert!(validate(&cfg).is_err());
1842 }
1843
1844 #[test]
1845 fn sandbox_config_defaults_to_off() {
1846 let cfg = MissionConfig::default();
1847 for role in [
1848 &cfg.orchestrator,
1849 &cfg.worker,
1850 &cfg.validator_scrutiny,
1851 &cfg.validator_functional,
1852 ] {
1853 assert_eq!(role.sandbox.enforce, crate::types::SandboxEnforce::Off);
1854 assert!(role.sandbox.extra_write.is_empty());
1855 assert!(role.sandbox.egress.is_empty());
1856 }
1857 assert!(validate(&cfg).is_ok());
1858 }
1859
1860 #[test]
1861 fn sandbox_config_parses_fs() {
1862 let dir = tempfile::tempdir().unwrap();
1863 let layer_path = dir.path().join("config.json");
1864 std::fs::write(
1865 &layer_path,
1866 r#"{"worker":{"sandbox":{"enforce":"fs","extraWrite":["~/.cargo"]}}}"#,
1867 )
1868 .unwrap();
1869
1870 let cfg = load_layers(&[layer_path]).unwrap();
1871 assert_eq!(cfg.worker.sandbox.enforce, crate::types::SandboxEnforce::Fs);
1872 assert_eq!(cfg.worker.sandbox.extra_write, vec!["~/.cargo".to_string()]);
1873 assert_eq!(
1875 cfg.orchestrator.sandbox.enforce,
1876 crate::types::SandboxEnforce::Off
1877 );
1878 }
1879
1880 #[test]
1881 fn sandbox_config_extra_write_roundtrips() {
1882 let mut cfg = MissionConfig::default();
1883 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1884 cfg.worker.sandbox.extra_write = vec!["~/.cargo".into(), "~/.npm".into()];
1885 cfg.worker.sandbox.egress = vec!["registry.npmjs.org:443".into()];
1886
1887 let value = serde_json::to_value(&cfg).unwrap();
1888 assert_eq!(value["worker"]["sandbox"]["enforce"], "fs+net");
1889 assert_eq!(
1890 value["worker"]["sandbox"]["extraWrite"],
1891 serde_json::json!(["~/.cargo", "~/.npm"])
1892 );
1893 assert_eq!(
1894 value["worker"]["sandbox"]["egress"],
1895 serde_json::json!(["registry.npmjs.org:443"])
1896 );
1897
1898 let roundtripped: MissionConfig = serde_json::from_value(value).unwrap();
1899 assert_eq!(roundtripped, cfg);
1900 }
1901
1902 #[test]
1903 fn sandbox_config_parses_fs_plus_net() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let layer_path = dir.path().join("config.json");
1906 std::fs::write(
1907 &layer_path,
1908 r#"{"worker":{"sandbox":{"enforce":"fs+net","egress":["crates.io:443"]}}}"#,
1909 )
1910 .unwrap();
1911
1912 let cfg = load_layers(&[layer_path]).unwrap();
1913 assert_eq!(
1914 cfg.worker.sandbox.enforce,
1915 crate::types::SandboxEnforce::FsNet
1916 );
1917 assert_eq!(cfg.worker.sandbox.egress, vec!["crates.io:443"]);
1918 }
1919
1920 #[test]
1921 fn only_claude_declares_sandbox_enforcement_support() {
1922 assert!(BackendKind::Claude.supports_sandbox_enforcement());
1923 for kind in [
1924 BackendKind::Codex,
1925 BackendKind::Droid,
1926 BackendKind::Kimi,
1927 BackendKind::Local,
1928 BackendKind::Cursor,
1929 ] {
1930 assert!(
1931 !kind.supports_sandbox_enforcement(),
1932 "{kind:?} must not claim sandbox enforcement support"
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn validate_rejects_enforced_sandbox_on_non_claude_backends() {
1939 for backend in ["codex", "droid", "kimi", "cursor"] {
1940 for enforce in [
1941 crate::types::SandboxEnforce::Fs,
1942 crate::types::SandboxEnforce::FsNet,
1943 ] {
1944 let mut cfg = MissionConfig::default();
1945 cfg.validator_scrutiny.backend = Some(backend.into());
1946 cfg.validator_scrutiny.sandbox.enforce = enforce;
1947 let err = validate(&cfg).unwrap_err().to_string();
1948 assert!(err.contains("validatorScrutiny"), "{err}");
1951 assert!(err.contains(backend), "{err}");
1952 assert!(err.contains(enforce.as_str()), "{err}");
1953 assert!(err.contains("sandbox.enforce=off"), "{err}");
1954 assert!(err.contains("claude"), "{err}");
1955 }
1956 }
1957 }
1958
1959 #[test]
1960 fn validate_rejects_enforced_sandbox_on_codex_worker() {
1961 let mut cfg = MissionConfig::default();
1962 cfg.worker.backend = Some("codex".into());
1963 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1964 let err = validate(&cfg).unwrap_err().to_string();
1965 assert!(err.contains("worker.backend"), "{err}");
1966 assert!(err.contains("codex"), "{err}");
1967 assert!(err.contains("sandbox.enforce=off"), "{err}");
1968 }
1969
1970 #[test]
1971 fn validate_rejects_enforced_sandbox_on_local_backend() {
1972 let mut cfg = local_worker_cfg();
1975 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
1976 let err = validate(&cfg).unwrap_err().to_string();
1977 assert!(err.contains("local"), "{err}");
1978 assert!(err.contains("fs+net"), "{err}");
1979 }
1980
1981 #[test]
1982 fn validate_rejects_container_provider_on_non_claude_backend() {
1983 let mut cfg = MissionConfig::default();
1986 cfg.validator_scrutiny.backend = Some("droid".into());
1987 cfg.validator_scrutiny.sandbox.enforce = crate::types::SandboxEnforce::Fs;
1988 cfg.validator_scrutiny.sandbox.provider = crate::types::SandboxProvider::Container;
1989 assert!(validate(&cfg).is_err());
1990 }
1991
1992 #[test]
1993 fn container_net_boundary_is_hard_only_for_process_or_empty_egress() {
1994 assert!(crate::types::SandboxProvider::Process
1998 .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
1999 assert!(crate::types::SandboxProvider::Container.enforces_hard_net_boundary(&[]));
2003 assert!(!crate::types::SandboxProvider::Container
2004 .enforces_hard_net_boundary(&["crates.io:443".to_string()]));
2005 }
2006
2007 #[test]
2008 fn validate_accepts_container_fs_net_with_egress_list_for_runtime_resolution() {
2009 let mut cfg = MissionConfig::default();
2012 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2013 cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2014 cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2015 assert!(validate(&cfg).is_ok());
2016 }
2017
2018 #[test]
2019 fn validate_accepts_container_fs_and_container_fs_net_with_empty_egress() {
2020 for enforce in [
2024 crate::types::SandboxEnforce::Fs,
2025 crate::types::SandboxEnforce::FsNet,
2026 ] {
2027 let mut cfg = MissionConfig::default();
2028 cfg.worker.sandbox.enforce = enforce;
2029 cfg.worker.sandbox.provider = crate::types::SandboxProvider::Container;
2030 assert!(
2031 validate(&cfg).is_ok(),
2032 "container provider with sandbox.enforce={} and an empty egress list must validate",
2033 enforce.as_str()
2034 );
2035 }
2036 }
2037
2038 #[test]
2039 fn validate_accepts_process_fs_net_with_egress_list() {
2040 let mut cfg = MissionConfig::default();
2043 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::FsNet;
2044 cfg.worker.sandbox.egress = vec!["crates.io:443".into()];
2045 assert!(
2046 validate(&cfg).is_ok(),
2047 "process provider fs+net with an egress list must still validate"
2048 );
2049 }
2050
2051 #[test]
2052 fn validate_accepts_enforced_sandbox_on_claude_backend() {
2053 for backend in [None, Some("claude")] {
2054 for enforce in [
2055 crate::types::SandboxEnforce::Fs,
2056 crate::types::SandboxEnforce::FsNet,
2057 ] {
2058 let mut cfg = MissionConfig::default();
2059 cfg.worker.backend = backend.map(|s| s.to_string());
2060 cfg.worker.sandbox.enforce = enforce;
2061 assert!(
2062 validate(&cfg).is_ok(),
2063 "claude worker with sandbox.enforce={} must validate",
2064 enforce.as_str()
2065 );
2066 }
2067 }
2068 }
2069
2070 #[test]
2071 fn validate_accepts_sandbox_off_on_every_backend() {
2072 for backend in ["codex", "droid", "kimi", "cursor"] {
2073 let mut cfg = MissionConfig::default();
2074 cfg.validator_scrutiny.backend = Some(backend.into());
2075 assert_eq!(
2076 cfg.validator_scrutiny.sandbox.enforce,
2077 crate::types::SandboxEnforce::Off
2078 );
2079 assert!(
2080 validate(&cfg).is_ok(),
2081 "{backend} with sandbox.enforce=off must validate"
2082 );
2083 }
2084 assert!(
2085 validate(&local_worker_cfg()).is_ok(),
2086 "local with sandbox.enforce=off must validate"
2087 );
2088 }
2089
2090 fn local_role_cfg() -> crate::types::RoleConfig {
2091 crate::types::RoleConfig {
2092 backend: Some("local".into()),
2093 model: "my-local-model".into(),
2094 base_url: Some("http://localhost:8080".into()),
2095 context_budget: Some(8192),
2096 ..MissionConfig::default().worker
2097 }
2098 }
2099
2100 fn local_worker_cfg() -> MissionConfig {
2101 MissionConfig {
2102 worker: local_role_cfg(),
2103 allow_below_default_worker_model: true,
2104 ..MissionConfig::default()
2105 }
2106 }
2107
2108 fn acp_worker_cfg() -> MissionConfig {
2111 let mut cfg = MissionConfig {
2112 allow_below_default_worker_model: true,
2113 ..MissionConfig::default()
2114 };
2115 cfg.worker.backend = Some("acp".into());
2116 cfg.worker.acp_command = Some("/opt/bin/my-acp-agent".into());
2117 cfg.worker.acp_args = vec!["--serve".into()];
2118 cfg
2119 }
2120
2121 #[test]
2122 fn backend_acp_config_round_trips_and_parses() {
2123 assert_eq!(parse_backend(Some("acp")), Ok(BackendKind::Acp));
2124 let cfg = acp_worker_cfg();
2125 assert_eq!(cfg.backend_kind(Role::Worker), BackendKind::Acp);
2126 assert_eq!(BackendKind::Acp.as_str(), "acp");
2127 assert!(!BackendKind::Acp.supports_sandbox_enforcement());
2128 assert!(!BackendKind::Acp.reports_cache_read_tokens());
2129 assert!(!BackendKind::Acp.reports_cache_write_tokens());
2130 assert!(
2131 validate(&cfg).is_ok(),
2132 "worker + acpCommand + the below-default opt-in must validate"
2133 );
2134 }
2135
2136 #[test]
2137 fn backend_acp_config_requires_worker_role_and_command() {
2138 for role in [
2141 Role::Orchestrator,
2142 Role::ValidatorScrutiny,
2143 Role::ValidatorFunctional,
2144 ] {
2145 let mut cfg = acp_worker_cfg();
2146 let role_cfg = match role {
2147 Role::Orchestrator => &mut cfg.orchestrator,
2148 Role::ValidatorScrutiny => &mut cfg.validator_scrutiny,
2149 Role::ValidatorFunctional => &mut cfg.validator_functional,
2150 Role::Worker => unreachable!("loop excludes the worker"),
2151 };
2152 role_cfg.backend = Some("acp".into());
2153 role_cfg.acp_command = Some("/opt/bin/my-acp-agent".into());
2154 let err = validate(&cfg).expect_err("non-worker acp must be refused");
2155 assert!(
2156 err.to_string().contains("worker role only"),
2157 "refusal must name the role restriction: {err}"
2158 );
2159 }
2160
2161 let mut cfg = acp_worker_cfg();
2163 cfg.worker.acp_command = None;
2164 let err = validate(&cfg).expect_err("missing acpCommand must be refused");
2165 assert!(err.to_string().contains("acpCommand"), "{err}");
2166 cfg.worker.acp_command = Some(" ".into());
2167 assert!(validate(&cfg).is_err(), "blank acpCommand must be refused");
2168
2169 let mut cfg = acp_worker_cfg();
2172 cfg.allow_below_default_worker_model = false;
2173 let err = validate(&cfg).expect_err("below-default acp worker needs the opt-in");
2174 assert!(
2175 err.to_string().contains("allowBelowDefaultWorkerModel"),
2176 "{err}"
2177 );
2178 }
2179
2180 #[test]
2181 fn local_config_requires_base_url() {
2182 let mut cfg = local_worker_cfg();
2183
2184 cfg.worker.base_url = None;
2185 assert!(
2186 validate(&cfg).is_err(),
2187 "missing baseUrl should be rejected"
2188 );
2189
2190 cfg.worker.base_url = Some("not a url".into());
2191 assert!(
2192 validate(&cfg).is_err(),
2193 "unparseable baseUrl should be rejected"
2194 );
2195
2196 cfg.worker.base_url = Some("http://localhost:8080".into());
2197 assert!(
2198 validate(&cfg).is_ok(),
2199 "valid http baseUrl should be accepted"
2200 );
2201
2202 cfg.worker.base_url = Some("https://models.internal/v1".into());
2207 assert!(
2208 validate(&cfg).is_err(),
2209 "a remote baseUrl needs the operator's allowlist"
2210 );
2211 cfg.local_backend_allowed_hosts = vec!["models.internal".into()];
2212 assert!(
2213 validate(&cfg).is_ok(),
2214 "valid https baseUrl at an allowlisted host should be accepted"
2215 );
2216 cfg.local_backend_allowed_hosts.clear();
2217
2218 cfg.worker.base_url = Some("http://127.0.0.1".into());
2219 assert!(
2220 validate(&cfg).is_ok(),
2221 "bare ip host baseUrl should be accepted"
2222 );
2223
2224 cfg.worker.base_url = Some("http://:8080".into());
2225 assert!(
2226 validate(&cfg).is_err(),
2227 "host-less authority with port should be rejected"
2228 );
2229
2230 cfg.worker.base_url = Some("http://@".into());
2231 assert!(
2232 validate(&cfg).is_err(),
2233 "userinfo-only authority should be rejected"
2234 );
2235
2236 cfg.worker.base_url = Some("http://@:8080".into());
2237 assert!(
2238 validate(&cfg).is_err(),
2239 "userinfo with port and no host should be rejected"
2240 );
2241 }
2242
2243 #[test]
2244 fn local_config_requires_context_budget_in_range() {
2245 let mut cfg = local_worker_cfg();
2246
2247 cfg.worker.context_budget = Some(1023);
2248 assert!(validate(&cfg).is_err(), "1023 is below the floor");
2249
2250 cfg.worker.context_budget = Some(200_001);
2251 assert!(validate(&cfg).is_err(), "200001 is above the ceiling");
2252
2253 cfg.worker.context_budget = None;
2254 assert!(validate(&cfg).is_err(), "missing contextBudget is rejected");
2255
2256 cfg.worker.context_budget = Some(8192);
2257 assert!(validate(&cfg).is_ok(), "8192 is in range");
2258 }
2259
2260 #[test]
2261 fn local_config_rejects_out_of_range_temperature() {
2262 let mut cfg = local_worker_cfg();
2263
2264 cfg.worker.temperature = Some(2.1);
2265 assert!(validate(&cfg).is_err(), "2.1 is above the ceiling");
2266
2267 cfg.worker.temperature = Some(-0.1);
2268 assert!(validate(&cfg).is_err(), "-0.1 is below the floor");
2269
2270 cfg.worker.temperature = Some(0.7);
2271 assert!(validate(&cfg).is_ok(), "0.7 is in range");
2272
2273 cfg.worker.temperature = None;
2274 assert!(validate(&cfg).is_ok(), "absent temperature is fine");
2275 }
2276
2277 #[test]
2278 fn local_config_worker_below_default_needs_optin() {
2279 let mut cfg = local_worker_cfg();
2280 cfg.allow_below_default_worker_model = false;
2281 assert!(
2282 validate(&cfg).is_err(),
2283 "local worker below-default tier requires opt-in"
2284 );
2285
2286 cfg.allow_below_default_worker_model = true;
2287 assert!(
2288 validate(&cfg).is_ok(),
2289 "local worker accepted once opted in"
2290 );
2291 }
2292
2293 #[test]
2294 fn local_config_orchestrator_local_always_rejected() {
2295 let cfg = MissionConfig {
2296 orchestrator: local_role_cfg(),
2297 allow_below_default_worker_model: true,
2298 ..MissionConfig::default()
2299 };
2300 assert!(
2301 validate(&cfg).is_err(),
2302 "local orchestrator always fails the frontier floor"
2303 );
2304 }
2305
2306 #[test]
2307 fn local_config_model_tier_below_default_for_any_nonempty() {
2308 assert_eq!(
2309 model_tier(BackendKind::Local, "any-model-id"),
2310 Some(ModelTier::BelowDefault)
2311 );
2312 assert_eq!(model_tier(BackendKind::Local, ""), None);
2313 assert_eq!(model_tier(BackendKind::Local, " "), None);
2314 }
2315
2316 #[test]
2317 fn local_config_effective_model_passes_through_verbatim_and_never_panics() {
2318 assert_eq!(
2319 effective_model(Role::Worker, BackendKind::Local, "my-local-model"),
2320 "my-local-model"
2321 );
2322 assert_eq!(
2325 effective_model(Role::Worker, BackendKind::Local, "sonnet"),
2326 "sonnet"
2327 );
2328 }
2329
2330 #[test]
2331 fn task_class_routing_maps_execution_class_to_local() {
2332 assert_eq!(
2333 task_class_to_tier(Some("execution-class")),
2334 ExecutorTier::Local
2335 );
2336 }
2337
2338 #[test]
2339 fn task_class_routing_defaults_to_frontier() {
2340 assert_eq!(
2341 task_class_to_tier(Some("planning-class")),
2342 ExecutorTier::Frontier
2343 );
2344 assert_eq!(
2345 task_class_to_tier(Some("some-arbitrary-value")),
2346 ExecutorTier::Frontier
2347 );
2348 assert_eq!(task_class_to_tier(None), ExecutorTier::Frontier);
2349 }
2350
2351 #[test]
2352 fn task_class_routing_is_case_and_whitespace_insensitive() {
2353 assert_eq!(
2354 task_class_to_tier(Some(" Execution-Class ")),
2355 ExecutorTier::Local
2356 );
2357 }
2358
2359 fn test_local_endpoint() -> LocalEndpoint {
2360 LocalEndpoint {
2361 base_url: "http://127.0.0.1:8080".to_string(),
2362 context_budget: 16_384,
2363 temperature: Some(0.2),
2364 }
2365 }
2366
2367 #[test]
2368 fn executor_routing_applies_local_backend_when_execution_class_and_endpoint_configured() {
2369 let mut cfg = MissionConfig::default();
2370 let validator_scrutiny_before = cfg.validator_scrutiny.clone();
2371 let validator_functional_before = cfg.validator_functional.clone();
2372 let endpoint = test_local_endpoint();
2373
2374 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2375
2376 assert_eq!(applied, ExecutorTier::Local);
2377 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2378 assert_eq!(
2379 cfg.worker.base_url.as_deref(),
2380 Some(endpoint.base_url.as_str())
2381 );
2382 assert_eq!(cfg.worker.context_budget, Some(endpoint.context_budget));
2383 assert_eq!(cfg.worker.temperature, endpoint.temperature);
2384 assert!(cfg.allow_below_default_worker_model);
2385 assert_eq!(cfg.validator_scrutiny, validator_scrutiny_before);
2386 assert_eq!(cfg.validator_functional, validator_functional_before);
2387 }
2388
2389 #[test]
2390 fn executor_routing_applies_fail_safe_frontier_when_no_endpoint_configured() {
2391 let mut cfg = MissionConfig::default();
2392 let worker_backend_before = cfg.worker.backend.clone();
2393
2394 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, None);
2395
2396 assert_eq!(applied, ExecutorTier::Frontier);
2397 assert_eq!(cfg.worker.backend, worker_backend_before);
2398 assert!(!cfg.allow_below_default_worker_model);
2399 }
2400
2401 #[test]
2402 fn executor_routing_applies_no_change_for_frontier_tier() {
2403 let mut cfg = MissionConfig::default();
2404 let before = cfg.clone();
2405 let endpoint = test_local_endpoint();
2406
2407 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Frontier, Some(&endpoint));
2408
2409 assert_eq!(applied, ExecutorTier::Frontier);
2410 assert_eq!(cfg, before);
2411 }
2412
2413 #[test]
2414 fn route_task_class_executor_routes_local_when_endpoint_configured() {
2415 let mut cfg = MissionConfig::default();
2419 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2420 cfg.worker.context_budget = Some(16_384);
2421
2422 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2423
2424 assert_eq!(applied, ExecutorTier::Local);
2425 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2426 assert_eq!(summary, "executor routed local (execution-class)");
2427 }
2428
2429 #[test]
2430 fn route_task_class_executor_stays_frontier_without_endpoint() {
2431 let mut cfg = MissionConfig::default();
2432 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2433
2434 assert_eq!(applied, ExecutorTier::Frontier);
2435 assert_eq!(cfg.worker.backend, None);
2436 assert!(summary.contains("no local endpoint configured"));
2437 }
2438
2439 #[test]
2440 fn route_task_class_executor_stays_frontier_for_non_execution_class() {
2441 let mut cfg = MissionConfig::default();
2442 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2443 cfg.worker.context_budget = Some(16_384);
2444
2445 let (applied, summary) = route_task_class_executor(&mut cfg, None);
2446
2447 assert_eq!(applied, ExecutorTier::Frontier);
2448 assert_eq!(cfg.worker.backend, None);
2449 assert_eq!(summary, "executor stays frontier");
2450 }
2451
2452 #[test]
2453 fn validator_stays_frontier_after_local_executor_routing() {
2454 let mut cfg = MissionConfig::default();
2455 let endpoint = test_local_endpoint();
2456
2457 apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2458
2459 assert_ne!(cfg.validator_scrutiny.backend.as_deref(), Some("local"));
2460 assert_ne!(cfg.validator_functional.backend.as_deref(), Some("local"));
2461 }
2462
2463 use crate::types::TaskClassRoute;
2468
2469 fn routing_table(rules: &[(&str, ExecutorTier)]) -> Vec<TaskClassRoute> {
2470 rules
2471 .iter()
2472 .map(|(task_class, tier)| TaskClassRoute {
2473 task_class: task_class.to_string(),
2474 tier: *tier,
2475 })
2476 .collect()
2477 }
2478
2479 #[test]
2480 fn routing_abstraction_table_defaults_empty_and_parses_camel_case() {
2481 assert!(MissionConfig::default().routing.task_class_rules.is_empty());
2485 let value = serde_json::to_value(MissionConfig::default()).unwrap();
2486 assert_eq!(value["routing"]["taskClassRules"], serde_json::json!([]));
2487
2488 let dir = tempfile::tempdir().unwrap();
2489 let layer_path = dir.path().join("config.json");
2490 std::fs::write(
2491 &layer_path,
2492 r#"{"routing": {"taskClassRules": [{"taskClass": "execution-class", "tier": "local"}, {"taskClass": "docs-class", "tier": "frontier"}]}}"#,
2493 )
2494 .unwrap();
2495 let cfg = load_layers(&[layer_path]).unwrap();
2496 assert_eq!(cfg.routing.task_class_rules.len(), 2);
2497 assert_eq!(
2498 cfg.routing.task_class_rules[0].task_class,
2499 "execution-class"
2500 );
2501 assert_eq!(cfg.routing.task_class_rules[0].tier, ExecutorTier::Local);
2502 assert_eq!(cfg.routing.task_class_rules[1].tier, ExecutorTier::Frontier);
2503
2504 let layer_path = dir.path().join("config-old.json");
2507 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2508 let cfg = load_layers(&[layer_path]).unwrap();
2509 assert!(cfg.routing.task_class_rules.is_empty());
2510 }
2511
2512 #[test]
2513 fn routing_abstraction_unconfigured_table_keeps_byte_identical_floor() {
2514 for task_class in [
2519 None,
2520 Some("execution-class"),
2521 Some(" Execution-Class "),
2522 Some("planning-class"),
2523 Some("some-arbitrary-value"),
2524 ] {
2525 for endpoint_configured in [false, true] {
2526 let wire = |cfg: &mut MissionConfig| {
2527 if endpoint_configured {
2528 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2529 cfg.worker.context_budget = Some(16_384);
2530 }
2531 };
2532 let mut cfg = MissionConfig::default();
2533 wire(&mut cfg);
2534 assert!(cfg.routing.task_class_rules.is_empty());
2535 let (applied, _) = route_task_class_executor(&mut cfg, task_class);
2536
2537 let mut reference = MissionConfig::default();
2539 wire(&mut reference);
2540 let endpoint = match (&reference.worker.base_url, reference.worker.context_budget) {
2541 (Some(base_url), Some(context_budget)) => Some(LocalEndpoint {
2542 base_url: base_url.clone(),
2543 context_budget,
2544 temperature: reference.worker.temperature,
2545 }),
2546 _ => None,
2547 };
2548 let expected = apply_executor_routing(
2549 &mut reference,
2550 task_class_to_tier(task_class),
2551 endpoint.as_ref(),
2552 );
2553
2554 assert_eq!(applied, expected, "task class {task_class:?}");
2555 assert_eq!(
2556 cfg, reference,
2557 "an empty table must apply byte-identical config changes for {task_class:?}"
2558 );
2559 }
2560 }
2561 }
2562
2563 #[test]
2564 fn routing_abstraction_table_routes_configured_class_to_local() {
2565 let mut cfg = MissionConfig::default();
2568 cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2569 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2570 cfg.worker.context_budget = Some(16_384);
2571
2572 let (applied, summary) = route_task_class_executor(&mut cfg, Some("docs-class"));
2573
2574 assert_eq!(applied, ExecutorTier::Local);
2575 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2576 assert_eq!(summary, "executor routed local (routing-table rule)");
2577
2578 let mut cfg = MissionConfig::default();
2582 cfg.routing.task_class_rules = routing_table(&[("docs-class", ExecutorTier::Local)]);
2583 cfg.worker.base_url = Some("http://127.0.0.1:8080".to_string());
2584 cfg.worker.context_budget = Some(16_384);
2585
2586 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2587
2588 assert_eq!(applied, ExecutorTier::Frontier);
2589 assert_eq!(cfg.worker.backend, None);
2590 assert_eq!(summary, "executor stays frontier");
2591 }
2592
2593 #[test]
2594 fn routing_abstraction_table_local_route_fails_safe_without_endpoint() {
2595 let mut cfg = MissionConfig::default();
2599 cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2600
2601 let (applied, summary) = route_task_class_executor(&mut cfg, Some("execution-class"));
2602
2603 assert_eq!(applied, ExecutorTier::Frontier);
2604 assert_eq!(cfg.worker.backend, None);
2605 assert!(
2606 summary.contains("no local endpoint configured"),
2607 "{summary}"
2608 );
2609 }
2610
2611 #[test]
2612 fn routing_abstraction_validate_fails_closed_on_malformed_table() {
2613 let mut cfg = MissionConfig::default();
2616 cfg.routing.task_class_rules = routing_table(&[
2617 ("execution-class", ExecutorTier::Local),
2618 (" Execution-Class", ExecutorTier::Frontier),
2619 ]);
2620 let err = validate(&cfg).unwrap_err().to_string();
2621 assert!(err.contains("routing.taskClassRules[1].taskClass"), "{err}");
2622 assert!(err.contains("duplicates rule 0"), "{err}");
2623
2624 let mut cfg = MissionConfig::default();
2626 cfg.routing.task_class_rules = routing_table(&[(" ", ExecutorTier::Local)]);
2627 let err = validate(&cfg).unwrap_err().to_string();
2628 assert!(err.contains("routing.taskClassRules[0].taskClass"), "{err}");
2629
2630 let mut cfg = MissionConfig::default();
2632 cfg.routing.task_class_rules = routing_table(&[
2633 ("execution-class", ExecutorTier::Local),
2634 ("docs-class", ExecutorTier::Frontier),
2635 ]);
2636 assert!(validate(&cfg).is_ok(), "a clean table must validate");
2637 }
2638
2639 #[test]
2640 fn routing_abstraction_hosted_fine_tune_is_plain_local_endpoint_config() {
2641 let mut cfg = local_worker_cfg();
2647 cfg.worker.base_url = Some("https://models.internal.example/v1".into());
2648 cfg.worker.model = "ft:some-model:some-org:some-id".into();
2649 cfg.local_backend_allowed_hosts = vec!["models.internal.example".into()];
2653 assert!(
2654 validate(&cfg).is_ok(),
2655 "a hosted fine-tune endpoint is ordinary local-backend config"
2656 );
2657
2658 cfg.routing.task_class_rules = routing_table(&[("execution-class", ExecutorTier::Local)]);
2659 let (applied, _) = route_task_class_executor(&mut cfg, Some("execution-class"));
2660 assert_eq!(applied, ExecutorTier::Local);
2661 assert_eq!(cfg.worker.backend.as_deref(), Some("local"));
2662 }
2663
2664 use crate::types::CandidateSpec;
2669
2670 fn dispatch_pool_pair() -> Vec<CandidateSpec> {
2671 vec![
2672 CandidateSpec {
2673 backend: "claude".into(),
2674 model: "sonnet".into(),
2675 },
2676 CandidateSpec {
2677 backend: "codex".into(),
2678 model: DEFAULT_CODEX_MODEL.into(),
2679 },
2680 ]
2681 }
2682
2683 #[test]
2684 fn dispatch_pool_defaults_empty_and_parses_camel_case() {
2685 assert!(MissionConfig::default().worker_candidates.is_empty());
2689 let value = serde_json::to_value(MissionConfig::default()).unwrap();
2690 assert_eq!(value["workerCandidates"], serde_json::json!([]));
2691
2692 let dir = tempfile::tempdir().unwrap();
2693 let layer_path = dir.path().join("config.json");
2694 std::fs::write(
2695 &layer_path,
2696 r#"{"workerCandidates": [{"backend": "claude", "model": "sonnet"}, {"backend": "codex", "model": "gpt-5.6-sol"}]}"#,
2697 )
2698 .unwrap();
2699 let cfg = load_layers(&[layer_path]).unwrap();
2700 assert_eq!(cfg.worker_candidates, dispatch_pool_pair());
2701
2702 let layer_path = dir.path().join("config-old.json");
2705 std::fs::write(&layer_path, r#"{"maxRespawns": 3}"#).unwrap();
2706 let cfg = load_layers(&[layer_path]).unwrap();
2707 assert!(cfg.worker_candidates.is_empty());
2708 }
2709
2710 #[test]
2711 fn dispatch_pool_validate_accepts_heterogeneous_pair() {
2712 let cfg = MissionConfig {
2713 worker_candidates: dispatch_pool_pair(),
2714 ..MissionConfig::default()
2715 };
2716 validate(&cfg).unwrap();
2717 }
2718
2719 #[test]
2720 fn dispatch_pool_validate_rejects_single_entry() {
2721 let cfg = MissionConfig {
2722 worker_candidates: vec![CandidateSpec {
2723 backend: "claude".into(),
2724 model: "sonnet".into(),
2725 }],
2726 ..MissionConfig::default()
2727 };
2728 let err = validate(&cfg).unwrap_err().to_string();
2729 assert!(
2730 err.contains("workerCandidates with exactly one entry"),
2731 "{err}"
2732 );
2733 }
2734
2735 #[test]
2736 fn dispatch_pool_validate_rejects_local_and_acp_candidates() {
2737 for backend in ["local", "acp"] {
2738 let cfg = MissionConfig {
2739 worker_candidates: vec![
2740 CandidateSpec {
2741 backend: "claude".into(),
2742 model: "sonnet".into(),
2743 },
2744 CandidateSpec {
2745 backend: backend.into(),
2746 model: "anything".into(),
2747 },
2748 ],
2749 ..MissionConfig::default()
2750 };
2751 let err = validate(&cfg).unwrap_err().to_string();
2752 assert!(
2753 err.contains("not supported in this pass"),
2754 "{backend}: {err}"
2755 );
2756 }
2757 }
2758
2759 #[test]
2760 fn dispatch_pool_validate_rejects_parallel_workers_combination() {
2761 let cfg = MissionConfig {
2762 worker_candidates: dispatch_pool_pair(),
2763 max_parallel_workers: 2,
2764 ..MissionConfig::default()
2765 };
2766 let err = validate(&cfg).unwrap_err().to_string();
2767 assert!(err.contains("mutually exclusive"), "{err}");
2768 }
2769
2770 #[test]
2771 fn dispatch_pool_validate_rejects_unknown_backend_and_model() {
2772 let cfg = MissionConfig {
2773 worker_candidates: vec![
2774 CandidateSpec {
2775 backend: "claude".into(),
2776 model: "sonnet".into(),
2777 },
2778 CandidateSpec {
2779 backend: "gemini".into(),
2780 model: "sonnet".into(),
2781 },
2782 ],
2783 ..MissionConfig::default()
2784 };
2785 let err = validate(&cfg).unwrap_err().to_string();
2786 assert!(err.contains("workerCandidates[1].backend"), "{err}");
2787
2788 let cfg = MissionConfig {
2789 worker_candidates: vec![
2790 CandidateSpec {
2791 backend: "claude".into(),
2792 model: "sonnet".into(),
2793 },
2794 CandidateSpec {
2795 backend: "codex".into(),
2796 model: "kranz-test-model".into(),
2797 },
2798 ],
2799 ..MissionConfig::default()
2800 };
2801 let err = validate(&cfg).unwrap_err().to_string();
2802 assert!(err.contains("not supported by backend"), "{err}");
2803 }
2804
2805 #[test]
2806 fn dispatch_pool_validate_enforces_worker_floor_per_candidate() {
2807 let mut cfg = MissionConfig {
2810 worker_candidates: vec![
2811 CandidateSpec {
2812 backend: "claude".into(),
2813 model: "sonnet".into(),
2814 },
2815 CandidateSpec {
2816 backend: "droid".into(),
2817 model: DEFAULT_DROID_MODEL.into(),
2818 },
2819 ],
2820 ..MissionConfig::default()
2821 };
2822 let err = validate(&cfg).unwrap_err().to_string();
2823 assert!(err.contains("below the default worker tier"), "{err}");
2824 cfg.allow_below_default_worker_model = true;
2825 validate(&cfg).unwrap();
2826 }
2827
2828 #[test]
2829 fn dispatch_pool_validate_rejects_sandboxed_non_claude_candidate() {
2830 let mut cfg = MissionConfig {
2831 worker_candidates: dispatch_pool_pair(),
2832 ..MissionConfig::default()
2833 };
2834 cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
2835 let err = validate(&cfg).unwrap_err().to_string();
2836 assert!(err.contains("cannot honor sandbox.enforce"), "{err}");
2837 }
2838
2839 #[test]
2840 fn dispatch_pool_executor_routing_never_goes_local() {
2841 let mut cfg = MissionConfig {
2846 worker_candidates: dispatch_pool_pair(),
2847 ..MissionConfig::default()
2848 };
2849 let endpoint = test_local_endpoint();
2850 let applied = apply_executor_routing(&mut cfg, ExecutorTier::Local, Some(&endpoint));
2851 assert_eq!(applied, ExecutorTier::Frontier);
2852 assert!(cfg.worker.backend.is_none());
2853 }
2854
2855 trait RoleConfigTestExt {
2856 fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig;
2857 }
2858
2859 impl RoleConfigTestExt for MissionConfig {
2860 fn role_mut_for_test(&mut self, role: Role) -> &mut crate::types::RoleConfig {
2861 match role {
2862 Role::Orchestrator => &mut self.orchestrator,
2863 Role::Worker => &mut self.worker,
2864 Role::ValidatorScrutiny => &mut self.validator_scrutiny,
2865 Role::ValidatorFunctional => &mut self.validator_functional,
2866 }
2867 }
2868 }
2869}