From 1c1559c483bbf78f21febdba39fac606ce57bea8 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 22:39:36 +0300 Subject: [PATCH 01/30] test(workflows): specify route enforcement contract Add two-sided failing-before coverage for exact route admission, fallback, context, descendant parity, and provider credit ceilings. Agent: Nausicaa --- codex-rs/workflows/src/lib.rs | 7 + codex-rs/workflows/src/routing_enforcement.rs | 239 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 codex-rs/workflows/src/routing_enforcement.rs diff --git a/codex-rs/workflows/src/lib.rs b/codex-rs/workflows/src/lib.rs index 829599874..5d0df0d83 100644 --- a/codex-rs/workflows/src/lib.rs +++ b/codex-rs/workflows/src/lib.rs @@ -8,6 +8,7 @@ mod ancient_names; mod branch_prompt; mod error; mod parse; +mod routing_enforcement; mod spec; mod validation; @@ -16,6 +17,12 @@ pub use branch_prompt::render_workflow_branch_prompt; pub use error::WorkflowSpecError; pub use error::WorkflowSpecResult; pub use parse::parse_workflow_yaml; +pub use routing_enforcement::WorkflowEffectiveModelRoute; +pub use routing_enforcement::WorkflowProviderCreditControl; +pub use routing_enforcement::WorkflowProviderCreditTerminalAccounting; +pub use routing_enforcement::WorkflowRouteEnforcementError; +pub use routing_enforcement::WorkflowRouteReceipt; +pub use routing_enforcement::admit_workflow_model_route; pub use spec::WorkflowAgent; pub use spec::WorkflowApprovals; pub use spec::WorkflowArtifacts; diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs new file mode 100644 index 000000000..ed05ff643 --- /dev/null +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -0,0 +1,239 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::WorkflowEffectiveModelRoute; + use crate::WorkflowModelRoute; + use crate::WorkflowModelRouter; + use crate::WorkflowModelRoutingCapability; + use crate::WorkflowModelRoutingConstraints; + use crate::WorkflowModelRoutingContext; + use crate::WorkflowModelRoutingContract; + use crate::WorkflowModelRoutingDecision; + use crate::WorkflowModelRoutingDecisionStatus; + use crate::WorkflowModelRoutingFallback; + use crate::WorkflowModelRoutingRequest; + use crate::WorkflowProviderCreditControl; + use crate::admit_workflow_model_route; + + fn exact_route() -> WorkflowModelRoute { + WorkflowModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.4".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + routing: Some(WorkflowModelRoutingContract { + contract_version: "openrouter.route/v1".to_string(), + router: WorkflowModelRouter::OpenRouter, + request: WorkflowModelRoutingRequest { + requested_capability: WorkflowModelRoutingCapability::AgentWorker, + context: WorkflowModelRoutingContext { + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + worktree_mode: Some("isolated_worktree".to_string()), + ..Default::default() + }, + constraints: WorkflowModelRoutingConstraints { + allowed_model_gateways: vec!["openrouter".to_string()], + preferred_model_gateways: vec!["openrouter".to_string()], + allowed_providers: vec!["openrouter".to_string()], + preferred_providers: vec!["openrouter".to_string()], + allowed_models: vec!["openai/gpt-5.4".to_string()], + preferred_models: vec!["openai/gpt-5.4".to_string()], + allowed_reasoning: vec!["high".to_string()], + preferred_reasoning: vec!["high".to_string()], + allowed_service_tiers: vec!["priority".to_string()], + preferred_service_tiers: vec!["priority".to_string()], + allowed_auth_profiles: vec!["account007".to_string()], + allowed_approval_policies: vec!["never".to_string()], + allowed_permission_profiles: vec![":workspace".to_string()], + allowed_worktree_modes: vec!["isolated_worktree".to_string()], + max_context_tokens: Some(128_000), + budget_usd: None, + fallback_required: false, + }, + }, + decision: Some(WorkflowModelRoutingDecision { + status: WorkflowModelRoutingDecisionStatus::Selected, + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + explanation: Some("exact route selected".to_string()), + fallback: Some(WorkflowModelRoutingFallback { + used: false, + reason: None, + }), + warnings: Vec::new(), + errors: Vec::new(), + }), + }), + } + } + + fn effective_route() -> WorkflowEffectiveModelRoute { + WorkflowEffectiveModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.4".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + worktree_mode: "isolated_worktree".to_string(), + context_ceiling_tokens: Some(128_000), + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + } + } + + #[test] + fn fully_supported_exact_route_persists_immutable_requested_and_effective_receipt() { + let route = exact_route(); + let effective = effective_route(); + + let receipt = admit_workflow_model_route(&route, &effective).expect("route is supported"); + + assert_eq!(receipt.requested, route); + assert_eq!(receipt.effective, effective); + assert_eq!(receipt.enforce_provider_attempt(&effective), Ok(())); + assert_eq!(receipt.enforce_descendant(&effective), Ok(())); + assert_eq!( + receipt.terminal_credit_accounting(), + crate::WorkflowProviderCreditTerminalAccounting::NotRequested + ); + } + + #[test] + fn route_receipt_is_identical_across_restart_and_descendant_revalidation() { + let route = exact_route(); + let effective = effective_route(); + let first = admit_workflow_model_route(&route, &effective).expect("first admission"); + let restarted = admit_workflow_model_route(&route, &effective).expect("restart admission"); + + assert_eq!(restarted, first); + assert_eq!(restarted.enforce_descendant(&effective), Ok(())); + } + + #[test] + fn every_disallowed_or_mismatched_route_dimension_fails_closed() { + let cases: Vec<(&str, Box)> = vec![ + ("workflow_route_gateway_mismatch", Box::new(|route| route.model_gateway = "direct".to_string())), + ("workflow_route_provider_mismatch", Box::new(|route| route.provider = "openai".to_string())), + ("workflow_route_model_mismatch", Box::new(|route| route.model = "openai/gpt-4.1".to_string())), + ("workflow_route_reasoning_mismatch", Box::new(|route| route.reasoning = "medium".to_string())), + ("workflow_route_service_tier_mismatch", Box::new(|route| route.service_tier = Some("default".to_string()))), + ("workflow_route_auth_profile_mismatch", Box::new(|route| route.auth_profile = Some("account008".to_string()))), + ("workflow_route_approval_profile_mismatch", Box::new(|route| route.approval_policy = Some("on-request".to_string()))), + ("workflow_route_permission_profile_mismatch", Box::new(|route| route.permission_profile = Some(":read-only".to_string()))), + ("workflow_route_worktree_mode_mismatch", Box::new(|route| route.worktree_mode = "shared_repository".to_string())), + ("workflow_route_context_ceiling_exceeded", Box::new(|route| route.context_ceiling_tokens = Some(128_001))), + ]; + + for (expected_code, mutate) in cases { + let mut effective = effective_route(); + mutate(&mut effective); + let error = admit_workflow_model_route(&exact_route(), &effective) + .expect_err("mismatched route must fail closed"); + assert_eq!(error.code(), expected_code); + } + } + + #[test] + fn missing_required_fallback_fails_closed() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .fallback_required = true; + + let error = admit_workflow_model_route(&route, &effective_route()) + .expect_err("required fallback cannot be ignored"); + assert_eq!(error.code(), "workflow_route_required_fallback_missing"); + } + + #[test] + fn finite_budget_without_provider_reservation_fails_before_provider_attempt() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Unavailable; + + let error = admit_workflow_model_route(&route, &effective) + .expect_err("finite budgets require a provider reservation"); + assert_eq!(error.code(), "workflow_route_credit_ceiling_unavailable"); + } + + #[test] + fn exhausted_provider_credit_reservation_blocks_another_attempt_and_has_exact_readback() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "5.00".to_string(), + remaining_usd: "0.00".to_string(), + exhausted: true, + }; + + let error = admit_workflow_model_route(&route, &effective) + .expect_err("an exhausted reservation cannot admit provider work"); + assert_eq!(error.code(), "workflow_route_credit_ceiling_exhausted"); + } + + #[test] + fn reserved_credit_ceiling_is_immutable_and_terminal_accounting_is_exact() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "1.25".to_string(), + remaining_usd: "3.75".to_string(), + exhausted: false, + }; + + let receipt = admit_workflow_model_route(&route, &effective).expect("reserved route"); + assert_eq!(receipt.enforce_provider_attempt(&effective), Ok(())); + assert_eq!( + receipt.terminal_credit_accounting(), + crate::WorkflowProviderCreditTerminalAccounting::ProviderReadback { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "1.25".to_string(), + remaining_usd: "3.75".to_string(), + exhausted: false, + } + ); + } +} From 6b72cfcd78d66a709f72f6c3f28f94998d60aa97 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 22:54:24 +0300 Subject: [PATCH 02/30] feat(workflows): enforce immutable model routes Agent: Nausicaa --- codex-rs/app-server/src/extensions.rs | 17 + .../background_agent_live.rs | 153 ++++- codex-rs/ext/workflows/src/activation.rs | 123 ++++ .../src/runtime/workflow_orchestrator.rs | 197 +++++- codex-rs/workflows/src/lib.rs | 2 + codex-rs/workflows/src/routing_enforcement.rs | 619 ++++++++++++++++++ 6 files changed, 1071 insertions(+), 40 deletions(-) diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 7e257fadc..028ae0b28 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -114,6 +114,23 @@ pub(crate) fn workflow_activation_config( codex_workflows_extension::WorkflowActivationConfig { auth_profile_ref: config.selected_auth_profile.clone(), permission_profile: config.permissions.permission_profile().clone(), + route_runtime: codex_workflows::WorkflowRouteRuntime { + model_gateway: Some(config.model_gateway_id.clone()), + provider: Some(config.model_provider_id.clone()), + model: config.model.clone(), + reasoning: config.model_reasoning_effort.as_ref().map(ToString::to_string), + service_tier: config.service_tier.clone(), + auth_profile: config.selected_auth_profile.clone(), + approval_policy: Some(config.permissions.approval_policy.value().to_string()), + permission_profile: config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + context_window_tokens: config + .model_context_window + .and_then(|tokens| u64::try_from(tokens).ok()), + credit_control: codex_workflows::WorkflowProviderCreditControl::Unavailable, + }, codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: config.features.use_legacy_landlock(), windows_sandbox_level: windows_sandbox_level_from_config(config), diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index b6d7011ba..bd8645c8a 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -118,6 +118,9 @@ use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowEffectiveModelRoute; +use codex_workflows::WorkflowRouteReceipt; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -2070,7 +2073,7 @@ async fn reconcile_background_agent_worker_processes( continue; }; let worker_admission = - match revalidate_background_agent_worker_admission(&context, run.id.as_str()).await { + match revalidate_background_agent_worker_admission(&context, &run).await { Ok(worker_admission) => worker_admission, Err(err) => { fail_claimed_background_agent_worker_process( @@ -2211,15 +2214,19 @@ async fn reconcile_background_agent_worker_processes( async fn revalidate_background_agent_worker_admission( context: &BackgroundAgentProcessSupervisorContext, - run_id: &str, + run: &BackgroundAgentRun, ) -> anyhow::Result> { let snapshot = context .state_db - .get_background_agent_initial_execution_snapshot(run_id) + .get_background_agent_initial_execution_snapshot(run.id.as_str()) .await? .with_context(|| { - format!("background agent `{run_id}` is missing its initial execution context snapshot") + format!( + "background agent `{}` is missing its initial execution context snapshot", + run.id + ) })?; + validate_workflow_route_snapshot(run, &snapshot)?; let Some(admission) = worker_admission_from_snapshot(&snapshot.payload_json)? else { return Ok(None); }; @@ -3791,6 +3798,7 @@ async fn resolve_background_agent_config( "{BACKGROUND_AGENT_ADMISSION_PROFILE_MISMATCH}: loaded worker auth profile does not match the admitted run" ); } + enforce_workflow_route_config(run, &snapshot, &mut config)?; if let Some(retry_at) = background_agent_exact_profile_retry_at(context, &config, run.auth_profile_ref.as_deref()) .await @@ -3906,6 +3914,143 @@ fn validate_background_agent_initial_execution_snapshot( Ok(()) } +fn validate_workflow_route_snapshot( + run: &BackgroundAgentRun, + snapshot: &BackgroundAgentExecutionSnapshot, +) -> anyhow::Result> { + if run.source != "workflow" { + return Ok(None); + } + let receipt_value = snapshot + .payload_json + .get("routeReceipt") + .ok_or_else(|| anyhow::anyhow!("workflow_route_receipt_missing: workflow execution snapshot has no route receipt"))?; + let receipt = serde_json::from_value::(receipt_value.clone()) + .map_err(|err| anyhow::anyhow!("workflow_route_receipt_invalid: {err}"))?; + receipt.enforce_provider_attempt(&receipt.effective)?; + if !matches!( + receipt.effective.credit_control, + WorkflowProviderCreditControl::NotRequested + ) { + anyhow::bail!( + "workflow_route_credit_ceiling_unavailable: background-agent provider runtime has no pre-launch credit reservation controller" + ); + } + + let attestation = run.model_attestation.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: workflow background-agent run has no model attestation" + ) + })?; + if attestation.requested_model.as_deref() != Some(receipt.requested.model.as_str()) + || attestation.applied_model.as_deref() != Some(receipt.effective.model.as_str()) + || attestation.requested_configuration.get("routeReceipt") != Some(receipt_value) + || attestation.applied_configuration.get("routeReceipt") != Some(receipt_value) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow model attestation differs from its execution snapshot" + ); + } + if run.auth_profile_ref.as_deref() != receipt.effective.auth_profile.as_deref() { + anyhow::bail!( + "workflow_route_auth_profile_mismatch: admitted auth profile differs from the immutable route receipt" + ); + } + let payload = snapshot.payload_json.as_object().ok_or_else(|| { + anyhow::anyhow!("workflow_route_receipt_invalid: workflow execution snapshot is not an object") + })?; + let scalar_matches = |key: &str, expected: Option<&str>| { + payload + .get(key) + .map_or(expected.is_none(), |value| value.as_str() == expected) + }; + if !scalar_matches("modelGateway", Some(receipt.effective.model_gateway.as_str())) + || !scalar_matches("provider", Some(receipt.effective.provider.as_str())) + || !scalar_matches("model", Some(receipt.effective.model.as_str())) + || !scalar_matches("reasoning", Some(receipt.effective.reasoning.as_str())) + || !scalar_matches("serviceTier", receipt.effective.service_tier.as_deref()) + || !scalar_matches( + "approvalPolicy", + receipt.effective.approval_policy.as_deref(), + ) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow execution snapshot route fields differ from its immutable receipt" + ); + } + if payload.get("contextCeilingTokens") + != Some(&serde_json::to_value(receipt.effective.context_ceiling_tokens)?) + || payload.get("creditAccounting") + != Some(&serde_json::to_value(receipt.terminal_credit_accounting())?) + { + anyhow::bail!( + "workflow_route_receipt_mismatch: workflow execution snapshot accounting differs from its immutable receipt" + ); + } + let workspace_mode = payload + .get("workspace") + .and_then(|workspace| workspace.get("mode")) + .and_then(Value::as_str); + if workspace_mode != Some(receipt.effective.worktree_mode.as_str()) { + anyhow::bail!( + "workflow_route_worktree_mode_mismatch: execution workspace differs from the immutable route receipt" + ); + } + Ok(Some(receipt)) +} + +fn enforce_workflow_route_config( + run: &BackgroundAgentRun, + snapshot: &BackgroundAgentExecutionSnapshot, + config: &mut codex_core::config::Config, +) -> anyhow::Result<()> { + let Some(receipt) = validate_workflow_route_snapshot(run, snapshot)? else { + return Ok(()); + }; + if let Some(context_ceiling_tokens) = receipt.effective.context_ceiling_tokens { + let context_ceiling_tokens = i64::try_from(context_ceiling_tokens).map_err(|_| { + anyhow::anyhow!( + "workflow_route_context_ceiling_invalid: context ceiling exceeds the runtime range" + ) + })?; + config.model_context_window = Some(context_ceiling_tokens); + config.model_auto_compact_token_limit = config + .model_auto_compact_token_limit + .map(|limit| limit.min(context_ceiling_tokens)); + } + let effective = WorkflowEffectiveModelRoute { + model_gateway: config.model_gateway_id.clone(), + provider: config.model_provider_id.clone(), + model: config.model.clone().ok_or_else(|| { + anyhow::anyhow!("workflow_route_model_unavailable: loaded worker model is missing") + })?, + reasoning: config + .model_reasoning_effort + .as_ref() + .map(ToString::to_string) + .ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_reasoning_unavailable: loaded worker reasoning is missing" + ) + })?, + service_tier: config.service_tier.clone(), + auth_profile: config.selected_auth_profile.clone(), + approval_policy: Some(config.permissions.approval_policy.value().to_string()), + permission_profile: config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + worktree_mode: receipt.effective.worktree_mode.clone(), + context_ceiling_tokens: config + .model_context_window + .and_then(|tokens| u64::try_from(tokens).ok()), + fallback_used: receipt.effective.fallback_used, + credit_control: WorkflowProviderCreditControl::NotRequested, + }; + receipt.enforce_provider_attempt(&effective)?; + Ok(()) +} + async fn insert_initial_goal_for_background_thread( state_db: &codex_state::StateRuntime, run_id: &str, diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index f0095ad4d..a3b45ddd7 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -44,7 +44,14 @@ use codex_state::WorkflowRunVerifierRecordResultParams; use codex_state::WorkflowRunVerifierResultSummary; use codex_state::busy_retry::retry_on_busy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; +use codex_workflows::WorkflowRouteRuntime; use codex_workflows::WorkflowVerifier; +use codex_workflows::WorkflowWorkspaceMode; +use codex_workflows::admit_workflow_model_route_for_runtime; +use codex_workflows::parse_workflow_yaml; use codex_workflows::verifier_has_finite_review_artifact_contract; use serde::Deserialize; use serde::Serialize; @@ -182,6 +189,7 @@ where pub struct WorkflowActivationConfig { pub auth_profile_ref: Option, pub permission_profile: PermissionProfile, + pub route_runtime: WorkflowRouteRuntime, pub codex_linux_sandbox_exe: Option, pub use_legacy_landlock: bool, pub windows_sandbox_level: WindowsSandboxLevel, @@ -194,6 +202,18 @@ impl Default for WorkflowActivationConfig { Self { auth_profile_ref: None, permission_profile: PermissionProfile::read_only(), + route_runtime: WorkflowRouteRuntime { + model_gateway: None, + provider: None, + model: None, + reasoning: None, + service_tier: None, + auth_profile: None, + approval_policy: None, + permission_profile: None, + context_window_tokens: None, + credit_control: WorkflowProviderCreditControl::Unavailable, + }, codex_linux_sandbox_exe: None, use_legacy_landlock: false, windows_sandbox_level: WindowsSandboxLevel::Disabled, @@ -237,6 +257,14 @@ impl WorkflowActivationService { &self, request: WorkflowStartRequest, ) -> anyhow::Result { + let spec_record = self + .state_db + .workflows() + .get_workflow_spec(request.workflow_record_id.as_str()) + .await? + .ok_or_else(|| anyhow::anyhow!("workflow spec record not found"))?; + let spec = parse_workflow_yaml(spec_record.source_yaml.as_str())?; + validate_workflow_routes_before_effects(&spec, &request.activation_config.route_runtime)?; let create_params = WorkflowRunCreateParams { workflow_record_id: request.workflow_record_id, source_thread_id: Some(request.source_thread_id), @@ -446,6 +474,7 @@ impl WorkflowActivationService { BACKGROUND_AGENT_RUNTIME_COMPATIBILITY_FINGERPRINT.to_string(), ), permission_profile_json: permission_profile_json.clone(), + route_runtime: config.route_runtime.clone(), parent_agent_run_id: None, max_active_background_agent_runs: config.max_active_background_agent_runs, }; @@ -566,6 +595,22 @@ impl WorkflowActivationService { .await; } let expected_exit_code = definition.expected_exit_code.unwrap_or(0); + if let Err(err) = validate_verifier_route(&claimed, &config.route_runtime) { + tracing::warn!( + workflow_run_id = %run_id, + verifier_run_id = %claimed.verifier.verifier_run_id, + "workflow verifier route is not admitted: {err}" + ); + return self + .record_failed_verifier_setup( + run_id, + generation, + claimed, + started, + Some(expected_exit_code), + ) + .await; + } let execution_root = match verifier_execution_root(&claimed) { Ok(execution_root) => execution_root, Err(err) => { @@ -654,6 +699,15 @@ impl WorkflowActivationService { let _ = heartbeat.await; return Ok(false); } + if let Err(err) = validate_verifier_route(&claimed, &config.route_runtime) { + tracing::warn!( + workflow_run_id = %run_id, + verifier_run_id = %claimed.verifier.verifier_run_id, + "workflow verifier route changed before command execution: {err}" + ); + passed = false; + break; + } command_count = command_count.saturating_add(1); let output = process_exec_tool_call( ExecParams { @@ -1330,21 +1384,90 @@ fn yaml_mapping_satisfies_requirement(mapping: &serde_yaml::Mapping, requirement serde_yaml::from_str::(expected).is_ok_and(|expected| expected == *actual) } +fn validate_verifier_route( + claimed: &WorkflowRunVerifierClaimOutcome, + runtime: &WorkflowRouteRuntime, +) -> anyhow::Result { + let requested_value = claimed.step.model_route_json.as_ref().ok_or_else(|| { + anyhow::anyhow!("workflow_route_receipt_missing: verifier step has no model route") + })?; + let requested = serde_json::from_value::( + workflow_state_data(requested_value).clone(), + )?; + let admitted_value = claimed + .step + .branch_admission_json + .as_ref() + .and_then(|admission| workflow_state_data(admission).get("routeReceipt")) + .ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: verifier step has no admitted route receipt" + ) + })?; + let admitted = serde_json::from_value::(admitted_value.clone())?; + let worktree_mode = match claimed + .step + .workspace_json + .as_ref() + .map(workflow_state_data) + .and_then(|workspace| workspace.get("mode")) + .and_then(serde_json::Value::as_str) + { + Some("isolated_worktree") => "isolated_worktree", + Some("shared_repository") => "shared_repository", + Some(other) => anyhow::bail!( + "workflow_route_worktree_mode_invalid: unsupported verifier worktree mode `{other}`" + ), + None => "shared_repository", + }; + let current = admit_workflow_model_route_for_runtime(&requested, runtime, worktree_mode)?; + if current != admitted { + anyhow::bail!( + "workflow_route_receipt_mismatch: verifier route differs from branch admission" + ); + } + admitted.enforce_provider_attempt(¤t.effective)?; + Ok(admitted) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct WorkflowActivationFingerprint<'a> { auth_profile_ref: Option<&'a str>, + route_runtime: &'a WorkflowRouteRuntime, admission_schema: &'static str, } fn activation_config_fingerprint(config: &WorkflowActivationConfig) -> anyhow::Result { let value = WorkflowActivationFingerprint { auth_profile_ref: config.auth_profile_ref.as_deref(), + route_runtime: &config.route_runtime, admission_schema: BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION, }; Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(&value)?))) } +fn validate_workflow_routes_before_effects( + spec: &codex_workflows::WorkflowSpec, + runtime: &WorkflowRouteRuntime, +) -> anyhow::Result<()> { + for step in &spec.steps { + let path = format!("steps.{}.model", step.id); + let route = step.model.as_ref().unwrap_or(&spec.execution_defaults); + let workspace_mode = step + .workspace + .as_ref() + .map_or(WorkflowWorkspaceMode::SharedRepository, |workspace| workspace.mode); + let worktree_mode = match workspace_mode { + WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", + WorkflowWorkspaceMode::SharedRepository => "shared_repository", + }; + admit_workflow_model_route_for_runtime(route, runtime, worktree_mode) + .map_err(|err| anyhow::anyhow!("{path}: {err}"))?; + } + Ok(()) +} + fn workflow_state_data(value: &serde_json::Value) -> &serde_json::Value { value.get("data").unwrap_or(value) } diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 51bbd1faf..994a7785d 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -23,9 +23,14 @@ use codex_git_utils::remove_linked_git_worktree; use codex_git_utils::resolve_git_ref; use codex_protocol::models::PermissionProfile; use codex_workflows::WorkflowBranchPrompt; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowRouteEnforcementError; +use codex_workflows::WorkflowRouteReceipt; +use codex_workflows::WorkflowRouteRuntime; use codex_workflows::WorkflowVerifier; use codex_workflows::WorkflowWorkspace; use codex_workflows::WorkflowWorkspaceMode; +use codex_workflows::admit_workflow_model_route_for_runtime; use codex_workflows::render_workflow_branch_prompt; use codex_workflows::verifier_has_finite_review_artifact_contract; use serde_json::Value; @@ -109,6 +114,7 @@ pub struct WorkflowRunBranchAdmissionParams { pub version_fingerprint: Option, pub runtime_package_fingerprint: Option, pub permission_profile_json: Value, + pub route_runtime: WorkflowRouteRuntime, pub parent_agent_run_id: Option, pub max_active_background_agent_runs: Option, } @@ -123,6 +129,7 @@ pub struct WorkflowRunBranchAdmission { pub idempotency_key: String, pub model_route_json: Value, pub workspace_json: Option, + pub route_receipt: WorkflowRouteReceipt, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -634,6 +641,8 @@ async fn admit_ready_workflow_branches_in_tx( provisioned_git_worktrees: &mut Vec, now_ms: i64, ) -> anyhow::Result { + let mut route_runtime = params.route_runtime.clone(); + route_runtime.auth_profile = params.auth_profile_ref.clone(); let limits = WorkflowBranchLimits::from_run(run)?; let active_counts = active_workflow_branch_counts_in_tx(tx, run.run_id.as_str()).await?; let mut capacity = limits @@ -658,6 +667,29 @@ async fn admit_ready_workflow_branches_in_tx( let mut changed = false; for candidate in &candidates { let model_route_json = branch_model_route_json(run, candidate.model_route_json.as_ref())?; + let workspace_json = optional_workflow_state_data(candidate.workspace_json.as_ref())?; + let route_receipt = workflow_branch_route_receipt( + &model_route_json, + workspace_json.as_ref(), + &route_runtime, + ); + if let Err(error) = route_receipt { + changed |= block_workflow_branch_provider_preflight_in_tx( + tx, + run, + params, + candidate, + &model_route_json, + &WorkflowBranchProviderPreflightBlock { + env_key: None, + reason: error.to_string(), + reason_code: error.code().to_string(), + }, + now_ms, + ) + .await?; + continue; + } let Some(preflight_block) = workflow_branch_provider_preflight_block(&model_route_json, provider_env_key_present) else { @@ -691,6 +723,11 @@ async fn admit_ready_workflow_branches_in_tx( let model_route_json = branch_model_route_json(run, candidate.model_route_json.as_ref())?; let workspace_json = optional_workflow_state_data(candidate.workspace_json.as_ref())?; let workspace_mode = workflow_workspace_mode(workspace_json.as_ref())?; + let route_receipt = workflow_branch_route_receipt( + &model_route_json, + workspace_json.as_ref(), + &route_runtime, + )?; if workspace_mode == WorkflowWorkspaceMode::IsolatedWorktree { if isolated_worktree_count >= limits.max_worktrees { continue; @@ -745,6 +782,8 @@ async fn admit_ready_workflow_branches_in_tx( "attempt": branch_attempt, "admittedAtMs": now_ms, "route": workflow_branch_route_summary(&model_route_json), + "routeReceipt": &route_receipt, + "terminalCreditAccounting": route_receipt.terminal_credit_accounting(), "workspace": workspace_json, "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), "cwd": provisioned_workspace.execution_cwd, @@ -796,6 +835,7 @@ WHERE step_run_id = ? candidate: &candidate, attempt: branch_attempt, model_route_json: &model_route_json, + route_receipt: &route_receipt, workspace_json: workspace_json.as_ref(), provisioned_workspace: &provisioned_workspace, review_context: review_context.as_ref(), @@ -822,6 +862,7 @@ WHERE step_run_id = ? "backgroundAgentRunId": background_agent_run_id, "createdBackgroundAgentRun": created, "route": workflow_branch_route_summary(&model_route_json), + "routeReceipt": &route_receipt, "workspaceMode": provisioned_workspace.mode, "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), }), @@ -838,6 +879,7 @@ WHERE step_run_id = ? idempotency_key, model_route_json, workspace_json, + route_receipt, }); } Ok(WorkflowRunBranchAdmissionTxOutcome { @@ -1068,9 +1110,9 @@ struct ReadyBranchCandidate { } struct WorkflowBranchProviderPreflightBlock { - env_key: &'static str, - reason: &'static str, - reason_code: &'static str, + env_key: Option<&'static str>, + reason: String, + reason_code: String, } struct BackgroundBranchRunCreate<'a> { @@ -1078,6 +1120,7 @@ struct BackgroundBranchRunCreate<'a> { candidate: &'a ReadyBranchCandidate, attempt: i64, model_route_json: &'a Value, + route_receipt: &'a WorkflowRouteReceipt, workspace_json: Option<&'a Value>, provisioned_workspace: &'a ProvisionedWorkflowWorkspace, review_context: Option<&'a WorkflowReviewBranchContext>, @@ -1234,8 +1277,8 @@ WHERE step_run_id = ? "#, ) .bind(crate::WorkflowRunStepStatus::Blocked.as_str()) - .bind(preflight_block.reason) - .bind(preflight_block.reason_code) + .bind(preflight_block.reason.as_str()) + .bind(preflight_block.reason_code.as_str()) .bind(now_ms) .bind(candidate.step_run_id.as_str()) .execute(&mut **tx) @@ -1260,8 +1303,8 @@ WHERE run_id = ? "#, ) .bind(crate::WorkflowRunStepVerifierStatus::Blocked.as_str()) - .bind(preflight_block.reason) - .bind(preflight_block.reason_code) + .bind(preflight_block.reason.as_str()) + .bind(preflight_block.reason_code.as_str()) .bind(now_ms) .bind(now_ms) .bind(run.run_id.as_str()) @@ -1282,7 +1325,7 @@ WHERE run_id = ? "stepId": candidate.step_id.as_str(), "agentId": candidate.agent_id.as_str(), "missingEnvKey": preflight_block.env_key, - "reasonCode": preflight_block.reason_code, + "reasonCode": preflight_block.reason_code.as_str(), "route": workflow_branch_route_summary(model_route_json), }), now_ms, @@ -1307,9 +1350,9 @@ fn workflow_branch_provider_preflight_block( } Some(WorkflowBranchProviderPreflightBlock { - env_key: OPENROUTER_API_KEY_ENV_VAR, - reason: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON, - reason_code: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE, + env_key: Some(OPENROUTER_API_KEY_ENV_VAR), + reason: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON.to_string(), + reason_code: WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string(), }) } @@ -1342,7 +1385,8 @@ async fn create_background_branch_run_if_missing_in_tx( run, candidate, attempt, - model_route_json, + model_route_json: _, + route_receipt, workspace_json, provisioned_workspace, review_context, @@ -1375,6 +1419,17 @@ async fn create_background_branch_run_if_missing_in_tx( "agentId": candidate.agent_id.as_str(), "parallelGroup": candidate.parallel_group.as_deref(), }); + let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; + let execution_payload = branch_execution_payload( + run, + candidate, + workspace_json, + provisioned_workspace, + review_context, + params, + recovery_policy, + route_receipt, + ); let run_params = BackgroundAgentRunCreateParams { id: background_agent_run_id.to_string(), idempotency_key: Some(idempotency_key.to_string()), @@ -1396,26 +1451,28 @@ async fn create_background_branch_run_if_missing_in_tx( status_reason: Some("queued by workflow branch admission".to_string()), config_fingerprint: params.config_fingerprint.clone(), version_fingerprint: params.version_fingerprint.clone(), - model_attestation: None, + model_attestation: Some(BackgroundAgentModelAttestationCreateParams { + requested_model: Some(route_receipt.requested.model.clone()), + requested_configuration: json!({ + "executionContext": &route_receipt.requested, + "routeReceipt": route_receipt, + }), + applied_model: Some(route_receipt.effective.model.clone()), + applied_configuration: json!({ + "executionContext": &execution_payload, + "routeReceipt": route_receipt, + }), + }), }; let start_event_payload = json!({ "cwd": provisioned_workspace.execution_cwd.to_string_lossy(), "prompt": prompt, "promptSnapshotRef": prompt_snapshot_ref, }); - let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; let execution_snapshot_params = BackgroundAgentExecutionSnapshotParams { run_id: background_agent_run_id.to_string(), snapshot_kind: "initial_execution_context".to_string(), - payload_json: branch_execution_payload( - run, - candidate, - model_route_json, - workspace_json, - provisioned_workspace, - review_context, - params, - ), + payload_json: execution_payload, recovery_policy: recovery_policy.to_string(), config_fingerprint: params.config_fingerprint.clone(), }; @@ -1810,11 +1867,12 @@ fn workflow_review_branch_context( fn branch_execution_payload( run: &crate::WorkflowRun, candidate: &ReadyBranchCandidate, - model_route_json: &Value, workspace_json: Option<&Value>, provisioned_workspace: &ProvisionedWorkflowWorkspace, review_context: Option<&WorkflowReviewBranchContext>, params: &WorkflowRunBranchAdmissionParams, + recovery_policy: &str, + route_receipt: &WorkflowRouteReceipt, ) -> Value { json!({ "snapshotSource": "workflow/branch_admission", @@ -1825,12 +1883,12 @@ fn branch_execution_payload( "cwd": provisioned_workspace.execution_cwd.to_string_lossy(), "workspaceRoots": [provisioned_workspace.worktree_path.to_string_lossy()], "managedWorktreeId": provisioned_workspace.worktree_id.as_str(), - "modelGateway": model_route_json.get("model_gateway"), - "model": model_route_json.get("model"), - "provider": model_route_json.get("provider"), - "reasoning": model_route_json.get("reasoning"), - "serviceTier": model_route_json.get("service_tier"), - "approvalPolicy": model_route_json.get("approval_policy"), + "modelGateway": route_receipt.effective.model_gateway.as_str(), + "model": route_receipt.effective.model.as_str(), + "provider": route_receipt.effective.provider.as_str(), + "reasoning": route_receipt.effective.reasoning.as_str(), + "serviceTier": route_receipt.effective.service_tier.as_deref(), + "approvalPolicy": route_receipt.effective.approval_policy.as_deref(), "permissionProfile": params.permission_profile_json.clone(), "authProfileIdentitySha256": params .auth_profile_ref @@ -1842,11 +1900,38 @@ fn branch_execution_payload( "configFingerprint": params.config_fingerprint, "versionFingerprint": params.version_fingerprint, "packageFingerprint": params.runtime_package_fingerprint, - "recoveryPolicy": WORKFLOW_BRANCH_RECOVERY_POLICY, + "recoveryPolicy": recovery_policy, "maxRuntimeSeconds": workflow_state_data(&run.limits_json).get("max_step_runtime_seconds"), + "routeReceipt": route_receipt, + "contextCeilingTokens": route_receipt.effective.context_ceiling_tokens, + "creditAccounting": route_receipt.terminal_credit_accounting(), }) } +fn workflow_branch_route_receipt( + model_route_json: &Value, + workspace_json: Option<&Value>, + runtime: &WorkflowRouteRuntime, +) -> Result { + let requested = serde_json::from_value::(model_route_json.clone()) + .map_err(|err| { + WorkflowRouteEnforcementError::new( + "workflow_route_schema_invalid", + format!("workflow route is invalid: {err}"), + ) + })?; + let worktree_mode = match workflow_workspace_mode(workspace_json).map_err(|err| { + WorkflowRouteEnforcementError::new( + "workflow_route_worktree_mode_invalid", + err.to_string(), + ) + })? { + WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", + WorkflowWorkspaceMode::SharedRepository => "shared_repository", + }; + admit_workflow_model_route_for_runtime(&requested, runtime, worktree_mode) +} + fn validate_workflow_permission_profile_json( permission_profile_json: &Value, ) -> anyhow::Result<()> { @@ -3212,7 +3297,7 @@ cleanup: .collect::(); let mut steps = (0..step_count) .map(|index| { - let route = if index == 0 { + let route = if index == 0 && title_suffix == "missing-openrouter-env" { r#" model: model_gateway: "openrouter" provider: "openrouter" @@ -3410,6 +3495,31 @@ agents: .expect("read-only permission profile should serialize") } + fn workflow_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(128_000), + ..Default::default() + } + } + + fn openrouter_workflow_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-oss-120b".to_string()), + reasoning: Some("xhigh".to_string()), + service_tier: Some("priority".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(128_000), + ..Default::default() + } + } + async fn mark_projected_node_complete( runtime: &StateRuntime, projection: &WorkflowGoalPlanProjectionOutcome, @@ -3898,6 +4008,7 @@ WHERE plan_id = ? AND key = ? version_fingerprint: Some("version-workflow".to_string()), runtime_package_fingerprint: Some("package-workflow".to_string()), permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4112,6 +4223,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: permission_profile_json.clone(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4192,6 +4304,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json, + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4304,6 +4417,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4439,8 +4553,9 @@ WHERE worktree_id = ? config_fingerprint: None, version_fingerprint: None, runtime_package_fingerprint: None, - permission_profile_json: read_only_permission_profile_json(), - parent_agent_run_id: None, + permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), + parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, |env_key| { @@ -4576,8 +4691,9 @@ WHERE worktree_id = ? config_fingerprint: None, version_fingerprint: None, runtime_package_fingerprint: None, - permission_profile_json: read_only_permission_profile_json(), - parent_agent_run_id: None, + permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), + parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, |_| true, @@ -4638,6 +4754,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4685,6 +4802,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4723,6 +4841,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4823,6 +4942,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4842,6 +4962,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4877,6 +4998,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -4947,6 +5069,7 @@ WHERE worktree_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -5102,6 +5225,7 @@ WHERE run_id = ? version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, @@ -5607,6 +5731,7 @@ cleanup: version_fingerprint: None, runtime_package_fingerprint: None, permission_profile_json: read_only_permission_profile_json(), + route_runtime: workflow_route_runtime(), parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }) diff --git a/codex-rs/workflows/src/lib.rs b/codex-rs/workflows/src/lib.rs index 5d0df0d83..e8249bcb0 100644 --- a/codex-rs/workflows/src/lib.rs +++ b/codex-rs/workflows/src/lib.rs @@ -22,7 +22,9 @@ pub use routing_enforcement::WorkflowProviderCreditControl; pub use routing_enforcement::WorkflowProviderCreditTerminalAccounting; pub use routing_enforcement::WorkflowRouteEnforcementError; pub use routing_enforcement::WorkflowRouteReceipt; +pub use routing_enforcement::WorkflowRouteRuntime; pub use routing_enforcement::admit_workflow_model_route; +pub use routing_enforcement::admit_workflow_model_route_for_runtime; pub use spec::WorkflowAgent; pub use spec::WorkflowApprovals; pub use spec::WorkflowArtifacts; diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index ed05ff643..05d3dd496 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -1,3 +1,622 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::WorkflowModelRoute; +use crate::WorkflowModelRoutingConstraints; +use crate::WorkflowModelRoutingDecisionStatus; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WorkflowProviderCreditControl { + NotRequested, + #[default] + Unavailable, + Reserved { + reservation_id: String, + ceiling_usd: String, + spent_usd: String, + remaining_usd: String, + exhausted: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowEffectiveModelRoute { + pub model_gateway: String, + pub provider: String, + pub model: String, + pub reasoning: String, + pub service_tier: Option, + pub auth_profile: Option, + pub approval_policy: Option, + pub permission_profile: Option, + pub worktree_mode: String, + pub context_ceiling_tokens: Option, + pub fallback_used: bool, + pub credit_control: WorkflowProviderCreditControl, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRouteRuntime { + pub model_gateway: Option, + pub provider: Option, + pub model: Option, + pub reasoning: Option, + pub service_tier: Option, + pub auth_profile: Option, + pub approval_policy: Option, + pub permission_profile: Option, + pub context_window_tokens: Option, + pub credit_control: WorkflowProviderCreditControl, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WorkflowProviderCreditTerminalAccounting { + NotRequested, + ProviderReadback { + reservation_id: String, + ceiling_usd: String, + spent_usd: String, + remaining_usd: String, + exhausted: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowRouteReceipt { + pub requested: WorkflowModelRoute, + pub effective: WorkflowEffectiveModelRoute, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{code}: {message}")] +pub struct WorkflowRouteEnforcementError { + code: &'static str, + message: String, +} + +impl WorkflowRouteEnforcementError { + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &'static str { + self.code + } +} + +pub fn admit_workflow_model_route( + requested: &WorkflowModelRoute, + effective: &WorkflowEffectiveModelRoute, +) -> Result { + enforce_exact( + "workflow_route_gateway_mismatch", + "model gateway", + requested.model_gateway.as_str(), + effective.model_gateway.as_str(), + )?; + enforce_exact( + "workflow_route_provider_mismatch", + "provider", + requested.provider.as_str(), + effective.provider.as_str(), + )?; + enforce_exact( + "workflow_route_model_mismatch", + "model", + requested.model.as_str(), + effective.model.as_str(), + )?; + enforce_exact( + "workflow_route_reasoning_mismatch", + "reasoning", + requested.reasoning.as_str(), + effective.reasoning.as_str(), + )?; + enforce_optional_exact( + "workflow_route_service_tier_mismatch", + "service tier", + requested.service_tier.as_deref(), + effective.service_tier.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_approval_profile_mismatch", + "approval profile", + requested.approval_policy.as_deref(), + effective.approval_policy.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_permission_profile_mismatch", + "permission profile", + requested.permission_profile.as_deref(), + effective.permission_profile.as_deref(), + )?; + + if let Some(routing) = requested.routing.as_ref() { + let Some(decision) = routing.decision.as_ref() else { + return Err(route_error( + "workflow_route_decision_missing", + "routing contract has no immutable decision", + )); + }; + if decision.status == WorkflowModelRoutingDecisionStatus::Error { + return Err(route_error( + "workflow_route_decision_error", + "routing decision is an error and cannot be executed", + )); + } + enforce_optional_exact( + "workflow_route_gateway_mismatch", + "routing decision model gateway", + decision.model_gateway.as_deref(), + Some(effective.model_gateway.as_str()), + )?; + enforce_optional_exact( + "workflow_route_provider_mismatch", + "routing decision provider", + decision.provider.as_deref(), + Some(effective.provider.as_str()), + )?; + enforce_optional_exact( + "workflow_route_model_mismatch", + "routing decision model", + decision.model.as_deref(), + Some(effective.model.as_str()), + )?; + enforce_optional_exact( + "workflow_route_reasoning_mismatch", + "routing decision reasoning", + decision.reasoning.as_deref(), + Some(effective.reasoning.as_str()), + )?; + enforce_optional_exact( + "workflow_route_service_tier_mismatch", + "routing decision service tier", + decision.service_tier.as_deref(), + effective.service_tier.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_auth_profile_mismatch", + "routing decision auth profile", + decision.auth_profile.as_deref(), + effective.auth_profile.as_deref(), + )?; + let context = &routing.request.context; + enforce_optional_exact( + "workflow_route_auth_profile_mismatch", + "routing context auth profile", + context.auth_profile.as_deref(), + effective.auth_profile.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_approval_profile_mismatch", + "routing context approval profile", + context.approval_policy.as_deref(), + effective.approval_policy.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_permission_profile_mismatch", + "routing context permission profile", + context.permission_profile.as_deref(), + effective.permission_profile.as_deref(), + )?; + enforce_optional_exact( + "workflow_route_worktree_mode_mismatch", + "routing context worktree mode", + context.worktree_mode.as_deref(), + Some(effective.worktree_mode.as_str()), + )?; + + let constraints = &routing.request.constraints; + enforce_constraints(constraints, decision.status, effective)?; + let decision_fallback_used = decision + .fallback + .as_ref() + .is_some_and(|fallback| fallback.used); + if decision_fallback_used != effective.fallback_used { + return Err(route_error( + "workflow_route_fallback_mismatch", + "effective fallback decision does not match the persisted routing decision", + )); + } + if constraints.fallback_required && !effective.fallback_used { + return Err(route_error( + "workflow_route_required_fallback_missing", + "routing requires an explicit fallback decision", + )); + } + enforce_credit_control(constraints.budget_usd.as_deref(), &effective.credit_control)?; + } else if !matches!( + effective.credit_control, + WorkflowProviderCreditControl::NotRequested + ) { + return Err(route_error( + "workflow_route_credit_control_unexpected", + "credit control was supplied without a routing budget contract", + )); + } + + Ok(WorkflowRouteReceipt { + requested: requested.clone(), + effective: effective.clone(), + }) +} + +pub fn admit_workflow_model_route_for_runtime( + requested: &WorkflowModelRoute, + runtime: &WorkflowRouteRuntime, + worktree_mode: &str, +) -> Result { + let constraints = requested + .routing + .as_ref() + .map(|routing| &routing.request.constraints); + let context_ceiling_tokens = constraints + .and_then(|constraints| constraints.max_context_tokens) + .map(|ceiling| { + let available = runtime.context_window_tokens.ok_or_else(|| { + route_error( + "workflow_route_context_ceiling_unavailable", + "runtime cannot prove the requested context ceiling before launch", + ) + })?; + if available < ceiling { + return Err(route_error( + "workflow_route_context_ceiling_unavailable", + "runtime context window is smaller than the requested immutable ceiling", + )); + } + Ok(ceiling) + }) + .transpose()?; + let fallback_used = requested + .routing + .as_ref() + .and_then(|routing| routing.decision.as_ref()) + .and_then(|decision| decision.fallback.as_ref()) + .is_some_and(|fallback| fallback.used); + let finite_budget_requested = constraints + .and_then(|constraints| constraints.budget_usd.as_ref()) + .is_some(); + let effective = WorkflowEffectiveModelRoute { + model_gateway: required_runtime_value( + "workflow_route_gateway_unavailable", + "model gateway", + runtime.model_gateway.as_deref(), + )?, + provider: required_runtime_value( + "workflow_route_provider_unavailable", + "provider", + runtime.provider.as_deref(), + )?, + model: required_runtime_value( + "workflow_route_model_unavailable", + "model", + runtime.model.as_deref(), + )?, + reasoning: required_runtime_value( + "workflow_route_reasoning_unavailable", + "reasoning", + runtime.reasoning.as_deref(), + )?, + service_tier: runtime.service_tier.clone(), + auth_profile: runtime.auth_profile.clone(), + approval_policy: runtime.approval_policy.clone(), + permission_profile: runtime.permission_profile.clone(), + worktree_mode: worktree_mode.to_string(), + context_ceiling_tokens, + fallback_used, + credit_control: if finite_budget_requested { + runtime.credit_control.clone() + } else { + WorkflowProviderCreditControl::NotRequested + }, + }; + admit_workflow_model_route(requested, &effective) +} + +impl WorkflowRouteReceipt { + pub fn enforce_provider_attempt( + &self, + effective: &WorkflowEffectiveModelRoute, + ) -> Result<(), WorkflowRouteEnforcementError> { + let repeated = admit_workflow_model_route(&self.requested, effective)?; + if repeated.effective != self.effective { + return Err(route_error( + "workflow_route_receipt_mismatch", + "provider attempt route differs from its immutable admission receipt", + )); + } + Ok(()) + } + + pub fn enforce_descendant( + &self, + effective: &WorkflowEffectiveModelRoute, + ) -> Result<(), WorkflowRouteEnforcementError> { + self.enforce_provider_attempt(effective) + } + + pub fn terminal_credit_accounting(&self) -> WorkflowProviderCreditTerminalAccounting { + match &self.effective.credit_control { + WorkflowProviderCreditControl::NotRequested => { + WorkflowProviderCreditTerminalAccounting::NotRequested + } + WorkflowProviderCreditControl::Reserved { + reservation_id, + ceiling_usd, + spent_usd, + remaining_usd, + exhausted, + } => WorkflowProviderCreditTerminalAccounting::ProviderReadback { + reservation_id: reservation_id.clone(), + ceiling_usd: ceiling_usd.clone(), + spent_usd: spent_usd.clone(), + remaining_usd: remaining_usd.clone(), + exhausted: *exhausted, + }, + WorkflowProviderCreditControl::Unavailable => { + unreachable!("unavailable credit control cannot be admitted") + } + } + } +} + +fn enforce_constraints( + constraints: &WorkflowModelRoutingConstraints, + decision_status: WorkflowModelRoutingDecisionStatus, + effective: &WorkflowEffectiveModelRoute, +) -> Result<(), WorkflowRouteEnforcementError> { + enforce_list( + "workflow_route_gateway_mismatch", + "model gateway", + effective.model_gateway.as_str(), + &constraints.allowed_model_gateways, + )?; + enforce_list( + "workflow_route_provider_mismatch", + "provider", + effective.provider.as_str(), + &constraints.allowed_providers, + )?; + enforce_list( + "workflow_route_model_mismatch", + "model", + effective.model.as_str(), + &constraints.allowed_models, + )?; + enforce_list( + "workflow_route_reasoning_mismatch", + "reasoning", + effective.reasoning.as_str(), + &constraints.allowed_reasoning, + )?; + enforce_optional_list( + "workflow_route_service_tier_mismatch", + "service tier", + effective.service_tier.as_deref(), + &constraints.allowed_service_tiers, + )?; + enforce_optional_list( + "workflow_route_auth_profile_mismatch", + "auth profile", + effective.auth_profile.as_deref(), + &constraints.allowed_auth_profiles, + )?; + enforce_optional_list( + "workflow_route_approval_profile_mismatch", + "approval profile", + effective.approval_policy.as_deref(), + &constraints.allowed_approval_policies, + )?; + enforce_optional_list( + "workflow_route_permission_profile_mismatch", + "permission profile", + effective.permission_profile.as_deref(), + &constraints.allowed_permission_profiles, + )?; + enforce_list( + "workflow_route_worktree_mode_mismatch", + "worktree mode", + effective.worktree_mode.as_str(), + &constraints.allowed_worktree_modes, + )?; + + if decision_status == WorkflowModelRoutingDecisionStatus::Selected { + enforce_list( + "workflow_route_gateway_not_preferred", + "preferred model gateway", + effective.model_gateway.as_str(), + &constraints.preferred_model_gateways, + )?; + enforce_list( + "workflow_route_provider_not_preferred", + "preferred provider", + effective.provider.as_str(), + &constraints.preferred_providers, + )?; + enforce_list( + "workflow_route_model_not_preferred", + "preferred model", + effective.model.as_str(), + &constraints.preferred_models, + )?; + enforce_list( + "workflow_route_reasoning_not_preferred", + "preferred reasoning", + effective.reasoning.as_str(), + &constraints.preferred_reasoning, + )?; + enforce_optional_list( + "workflow_route_service_tier_not_preferred", + "preferred service tier", + effective.service_tier.as_deref(), + &constraints.preferred_service_tiers, + )?; + } + + if let Some(max_context_tokens) = constraints.max_context_tokens { + let Some(context_ceiling_tokens) = effective.context_ceiling_tokens else { + return Err(route_error( + "workflow_route_context_ceiling_unavailable", + "runtime cannot establish the requested context ceiling", + )); + }; + if context_ceiling_tokens > max_context_tokens { + return Err(route_error( + "workflow_route_context_ceiling_exceeded", + "effective context ceiling exceeds the routing constraint", + )); + } + } + Ok(()) +} + +fn enforce_credit_control( + budget_usd: Option<&str>, + control: &WorkflowProviderCreditControl, +) -> Result<(), WorkflowRouteEnforcementError> { + match (budget_usd, control) { + (None, WorkflowProviderCreditControl::NotRequested) => Ok(()), + (Some(_), WorkflowProviderCreditControl::Unavailable) => Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "provider cannot establish a pre-launch credit reservation and accounting readback", + )), + ( + Some(budget_usd), + WorkflowProviderCreditControl::Reserved { + ceiling_usd, + remaining_usd, + exhausted, + .. + }, + ) => { + if ceiling_usd != budget_usd { + return Err(route_error( + "workflow_route_credit_ceiling_mismatch", + "provider reservation ceiling differs from the requested budget", + )); + } + if *exhausted || decimal_is_zero(remaining_usd) { + return Err(route_error( + "workflow_route_credit_ceiling_exhausted", + "provider reservation has no credit remaining", + )); + } + Ok(()) + } + (Some(_), WorkflowProviderCreditControl::NotRequested) => Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "finite routing budget has no provider credit reservation", + )), + (None, _) => Err(route_error( + "workflow_route_credit_control_unexpected", + "provider credit control was supplied without a finite routing budget", + )), + } +} + +fn decimal_is_zero(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && !value.starts_with('-') + && value + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + && value.chars().any(|character| character.is_ascii_digit()) + && value + .chars() + .filter(|character| character.is_ascii_digit()) + .all(|character| character == '0') +} + +fn enforce_exact( + code: &'static str, + field: &str, + expected: &str, + actual: &str, +) -> Result<(), WorkflowRouteEnforcementError> { + if expected.is_none() || expected == actual { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} does not match the exact requested value"), + )) + } +} + +fn enforce_optional_exact( + code: &'static str, + field: &str, + expected: Option<&str>, + actual: Option<&str>, +) -> Result<(), WorkflowRouteEnforcementError> { + if expected == actual { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} does not match the exact requested value"), + )) + } +} + +fn enforce_list( + code: &'static str, + field: &str, + actual: &str, + allowed: &[String], +) -> Result<(), WorkflowRouteEnforcementError> { + if allowed.is_empty() || allowed.iter().any(|value| value == actual) { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} is not allowed by the routing contract"), + )) + } +} + +fn enforce_optional_list( + code: &'static str, + field: &str, + actual: Option<&str>, + allowed: &[String], +) -> Result<(), WorkflowRouteEnforcementError> { + if allowed.is_empty() || actual.is_some_and(|actual| allowed.iter().any(|value| value == actual)) + { + Ok(()) + } else { + Err(route_error( + code, + format!("effective {field} is missing or not allowed by the routing contract"), + )) + } +} + +fn route_error(code: &'static str, message: impl Into) -> WorkflowRouteEnforcementError { + WorkflowRouteEnforcementError::new(code, message) +} + +fn required_runtime_value( + code: &'static str, + field: &str, + value: Option<&str>, +) -> Result { + value + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| route_error(code, format!("runtime {field} is unavailable"))) +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; From 2ac1f78c4686d3288f737792c17bdf824f92b9c3 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:01:48 +0300 Subject: [PATCH 03/30] test(workflows): harden route enforcement evidence Agent: Nausicaa --- codex-rs/app-server/README.md | 2 + codex-rs/app-server/src/extensions.rs | 5 +- .../background_agent_live.rs | 29 ++-- codex-rs/ext/workflows/src/activation.rs | 108 +++++++++++- .../src/runtime/workflow_orchestrator.rs | 17 +- codex-rs/workflows/src/routing_enforcement.rs | 157 ++++++++++++++++-- 6 files changed, 274 insertions(+), 44 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cd30f59b9..bf2423555 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1154,6 +1154,8 @@ Use `thread/workflow/get` to read one saved workflow metadata record, or `thread Use `thread/workflow/run/start` to start a saved workflow for the same thread. The response includes sanitized run state and the projected goal plan when projection succeeds. Use run list/get for inspection and pause/resume/cancel for lifecycle control. +Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work rather than applying a caller estimate after launch. + ```json { "method": "thread/workflow/run/start", "id": 36, "params": { "threadId": "thr_123", diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 028ae0b28..e8dbfb207 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -118,7 +118,10 @@ pub(crate) fn workflow_activation_config( model_gateway: Some(config.model_gateway_id.clone()), provider: Some(config.model_provider_id.clone()), model: config.model.clone(), - reasoning: config.model_reasoning_effort.as_ref().map(ToString::to_string), + reasoning: config + .model_reasoning_effort + .as_ref() + .map(ToString::to_string), service_tier: config.service_tier.clone(), auth_profile: config.selected_auth_profile.clone(), approval_policy: Some(config.permissions.approval_policy.value().to_string()), diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index bd8645c8a..9ff32c574 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -118,14 +118,14 @@ use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; -use codex_workflows::WorkflowProviderCreditControl; -use codex_workflows::WorkflowEffectiveModelRoute; -use codex_workflows::WorkflowRouteReceipt; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use codex_rollout::StateDbHandle; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_workflows::WorkflowEffectiveModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; use futures::future::join_all; use serde_json::Value; use serde_json::json; @@ -3921,10 +3921,11 @@ fn validate_workflow_route_snapshot( if run.source != "workflow" { return Ok(None); } - let receipt_value = snapshot - .payload_json - .get("routeReceipt") - .ok_or_else(|| anyhow::anyhow!("workflow_route_receipt_missing: workflow execution snapshot has no route receipt"))?; + let receipt_value = snapshot.payload_json.get("routeReceipt").ok_or_else(|| { + anyhow::anyhow!( + "workflow_route_receipt_missing: workflow execution snapshot has no route receipt" + ) + })?; let receipt = serde_json::from_value::(receipt_value.clone()) .map_err(|err| anyhow::anyhow!("workflow_route_receipt_invalid: {err}"))?; receipt.enforce_provider_attempt(&receipt.effective)?; @@ -3957,15 +3958,19 @@ fn validate_workflow_route_snapshot( ); } let payload = snapshot.payload_json.as_object().ok_or_else(|| { - anyhow::anyhow!("workflow_route_receipt_invalid: workflow execution snapshot is not an object") + anyhow::anyhow!( + "workflow_route_receipt_invalid: workflow execution snapshot is not an object" + ) })?; let scalar_matches = |key: &str, expected: Option<&str>| { payload .get(key) .map_or(expected.is_none(), |value| value.as_str() == expected) }; - if !scalar_matches("modelGateway", Some(receipt.effective.model_gateway.as_str())) - || !scalar_matches("provider", Some(receipt.effective.provider.as_str())) + if !scalar_matches( + "modelGateway", + Some(receipt.effective.model_gateway.as_str()), + ) || !scalar_matches("provider", Some(receipt.effective.provider.as_str())) || !scalar_matches("model", Some(receipt.effective.model.as_str())) || !scalar_matches("reasoning", Some(receipt.effective.reasoning.as_str())) || !scalar_matches("serviceTier", receipt.effective.service_tier.as_deref()) @@ -3979,7 +3984,9 @@ fn validate_workflow_route_snapshot( ); } if payload.get("contextCeilingTokens") - != Some(&serde_json::to_value(receipt.effective.context_ceiling_tokens)?) + != Some(&serde_json::to_value( + receipt.effective.context_ceiling_tokens, + )?) || payload.get("creditAccounting") != Some(&serde_json::to_value(receipt.terminal_credit_accounting())?) { diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index a3b45ddd7..aeffbb1fb 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -1391,9 +1391,8 @@ fn validate_verifier_route( let requested_value = claimed.step.model_route_json.as_ref().ok_or_else(|| { anyhow::anyhow!("workflow_route_receipt_missing: verifier step has no model route") })?; - let requested = serde_json::from_value::( - workflow_state_data(requested_value).clone(), - )?; + let requested = + serde_json::from_value::(workflow_state_data(requested_value).clone())?; let admitted_value = claimed .step .branch_admission_json @@ -1457,7 +1456,9 @@ fn validate_workflow_routes_before_effects( let workspace_mode = step .workspace .as_ref() - .map_or(WorkflowWorkspaceMode::SharedRepository, |workspace| workspace.mode); + .map_or(WorkflowWorkspaceMode::SharedRepository, |workspace| { + workspace.mode + }); let worktree_mode = match workspace_mode { WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", WorkflowWorkspaceMode::SharedRepository => "shared_repository", @@ -1568,6 +1569,105 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicUsize; + const ROUTE_ACTIVATION_WORKFLOW_YAML: &str = r#" +schema_version: "workflow.codex.codewith/v0" +workflow_id: "wf_route_activation" +display_name: "Route activation" +source_prompt: "Prove route admission happens before workflow effects." +status: "draft" +execution_defaults: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" +limits: + max_parallel_steps: 1 + max_agents: 1 + max_worktrees: 1 + max_runtime_seconds: 300 + max_step_runtime_seconds: 120 + max_tokens: 1000 + max_tool_calls: 10 +approvals: + required_before: [] +agents: + - id: "worker" + display_name: "Worker" + role: "Prove route activation." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" +steps: + - id: "run" + title: "Run exact route" + agent: "worker" + depends_on: [] + outputs: + - "result.md" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "result_present" + type: "artifact_contains" + artifact: "result.md" + must_contain: + - "done" +artifacts: + retention: "until_workflow_complete" + required: + - "result.md" +cleanup: + on_cancel: [] + on_complete: [] +"#; + + fn supported_route_runtime() -> WorkflowRouteRuntime { + WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: None, + auth_profile: None, + approval_policy: Some("never".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(256_000), + credit_control: WorkflowProviderCreditControl::Unavailable, + } + } + + #[test] + fn activation_route_gate_accepts_supported_exact_route_and_rejects_before_effects() { + let spec = parse_workflow_yaml(ROUTE_ACTIVATION_WORKFLOW_YAML) + .expect("activation workflow should parse"); + let supported = supported_route_runtime(); + + validate_workflow_routes_before_effects(&spec, &supported) + .expect("exact supported route should reach activation"); + + let mut unavailable = supported.clone(); + unavailable.provider = None; + let unavailable_error = validate_workflow_routes_before_effects(&spec, &unavailable) + .expect_err("missing provider must fail before activation effects"); + assert!( + unavailable_error + .to_string() + .contains("workflow_route_provider_unavailable") + ); + + let mut mismatched = supported; + mismatched.model = Some("gpt-5.3".to_string()); + let mismatch_error = validate_workflow_routes_before_effects(&spec, &mismatched) + .expect_err("mismatched model must fail before activation effects"); + assert!( + mismatch_error + .to_string() + .contains("workflow_route_model_mismatch") + ); + } + #[test] fn workflow_state_operation_labels_cover_every_activation_boundary() { assert_eq!( diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 994a7785d..67775eadf 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1921,10 +1921,7 @@ fn workflow_branch_route_receipt( ) })?; let worktree_mode = match workflow_workspace_mode(workspace_json).map_err(|err| { - WorkflowRouteEnforcementError::new( - "workflow_route_worktree_mode_invalid", - err.to_string(), - ) + WorkflowRouteEnforcementError::new("workflow_route_worktree_mode_invalid", err.to_string()) })? { WorkflowWorkspaceMode::IsolatedWorktree => "isolated_worktree", WorkflowWorkspaceMode::SharedRepository => "shared_repository", @@ -4553,9 +4550,9 @@ WHERE worktree_id = ? config_fingerprint: None, version_fingerprint: None, runtime_package_fingerprint: None, - permission_profile_json: read_only_permission_profile_json(), - route_runtime: openrouter_workflow_route_runtime(), - parent_agent_run_id: None, + permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), + parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, |env_key| { @@ -4691,9 +4688,9 @@ WHERE worktree_id = ? config_fingerprint: None, version_fingerprint: None, runtime_package_fingerprint: None, - permission_profile_json: read_only_permission_profile_json(), - route_runtime: openrouter_workflow_route_runtime(), - parent_agent_run_id: None, + permission_profile_json: read_only_permission_profile_json(), + route_runtime: openrouter_workflow_route_runtime(), + parent_agent_run_id: None, max_active_background_agent_runs: Some(10), }, |_| true, diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index 05d3dd496..309cda3ef 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -226,7 +226,10 @@ pub fn admit_workflow_model_route( "effective fallback decision does not match the persisted routing decision", )); } - if constraints.fallback_required && !effective.fallback_used { + if constraints.fallback_required + && (decision.status != WorkflowModelRoutingDecisionStatus::Fallback + || !effective.fallback_used) + { return Err(route_error( "workflow_route_required_fallback_missing", "routing requires an explicit fallback decision", @@ -543,7 +546,7 @@ fn enforce_exact( expected: &str, actual: &str, ) -> Result<(), WorkflowRouteEnforcementError> { - if expected.is_none() || expected == actual { + if expected == actual { Ok(()) } else { Err(route_error( @@ -559,7 +562,7 @@ fn enforce_optional_exact( expected: Option<&str>, actual: Option<&str>, ) -> Result<(), WorkflowRouteEnforcementError> { - if expected == actual { + if expected.is_none() || expected == actual { Ok(()) } else { Err(route_error( @@ -591,7 +594,8 @@ fn enforce_optional_list( actual: Option<&str>, allowed: &[String], ) -> Result<(), WorkflowRouteEnforcementError> { - if allowed.is_empty() || actual.is_some_and(|actual| allowed.iter().any(|value| value == actual)) + if allowed.is_empty() + || actual.is_some_and(|actual| allowed.iter().any(|value| value == actual)) { Ok(()) } else { @@ -620,6 +624,8 @@ fn required_runtime_value( #[cfg(test)] mod tests { use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; use crate::WorkflowEffectiveModelRoute; use crate::WorkflowModelRoute; @@ -634,6 +640,7 @@ mod tests { use crate::WorkflowModelRoutingRequest; use crate::WorkflowProviderCreditControl; use crate::admit_workflow_model_route; + use crate::admit_workflow_model_route_for_runtime; fn exact_route() -> WorkflowModelRoute { WorkflowModelRoute { @@ -713,6 +720,32 @@ mod tests { } } + fn runtime() -> crate::WorkflowRouteRuntime { + crate::WorkflowRouteRuntime { + model_gateway: Some("openrouter".to_string()), + provider: Some("openrouter".to_string()), + model: Some("openai/gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some(":workspace".to_string()), + context_window_tokens: Some(256_000), + credit_control: WorkflowProviderCreditControl::NotRequested, + } + } + + fn invoke_provider_after_route_admission( + requested: &WorkflowModelRoute, + effective: &WorkflowEffectiveModelRoute, + provider_calls: &AtomicUsize, + ) -> Result<(), crate::WorkflowRouteEnforcementError> { + let receipt = admit_workflow_model_route(requested, effective)?; + receipt.enforce_provider_attempt(effective)?; + provider_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + #[test] fn fully_supported_exact_route_persists_immutable_requested_and_effective_receipt() { let route = exact_route(); @@ -728,6 +761,10 @@ mod tests { receipt.terminal_credit_accounting(), crate::WorkflowProviderCreditTerminalAccounting::NotRequested ); + let provider_calls = AtomicUsize::new(0); + invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect("fully supported route can invoke the provider"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); } #[test] @@ -744,24 +781,98 @@ mod tests { #[test] fn every_disallowed_or_mismatched_route_dimension_fails_closed() { let cases: Vec<(&str, Box)> = vec![ - ("workflow_route_gateway_mismatch", Box::new(|route| route.model_gateway = "direct".to_string())), - ("workflow_route_provider_mismatch", Box::new(|route| route.provider = "openai".to_string())), - ("workflow_route_model_mismatch", Box::new(|route| route.model = "openai/gpt-4.1".to_string())), - ("workflow_route_reasoning_mismatch", Box::new(|route| route.reasoning = "medium".to_string())), - ("workflow_route_service_tier_mismatch", Box::new(|route| route.service_tier = Some("default".to_string()))), - ("workflow_route_auth_profile_mismatch", Box::new(|route| route.auth_profile = Some("account008".to_string()))), - ("workflow_route_approval_profile_mismatch", Box::new(|route| route.approval_policy = Some("on-request".to_string()))), - ("workflow_route_permission_profile_mismatch", Box::new(|route| route.permission_profile = Some(":read-only".to_string()))), - ("workflow_route_worktree_mode_mismatch", Box::new(|route| route.worktree_mode = "shared_repository".to_string())), - ("workflow_route_context_ceiling_exceeded", Box::new(|route| route.context_ceiling_tokens = Some(128_001))), + ( + "workflow_route_gateway_mismatch", + Box::new(|route| route.model_gateway = "direct".to_string()), + ), + ( + "workflow_route_provider_mismatch", + Box::new(|route| route.provider = "openai".to_string()), + ), + ( + "workflow_route_model_mismatch", + Box::new(|route| route.model = "openai/gpt-4.1".to_string()), + ), + ( + "workflow_route_reasoning_mismatch", + Box::new(|route| route.reasoning = "medium".to_string()), + ), + ( + "workflow_route_service_tier_mismatch", + Box::new(|route| route.service_tier = Some("default".to_string())), + ), + ( + "workflow_route_auth_profile_mismatch", + Box::new(|route| route.auth_profile = Some("account008".to_string())), + ), + ( + "workflow_route_approval_profile_mismatch", + Box::new(|route| route.approval_policy = Some("on-request".to_string())), + ), + ( + "workflow_route_permission_profile_mismatch", + Box::new(|route| route.permission_profile = Some(":read-only".to_string())), + ), + ( + "workflow_route_worktree_mode_mismatch", + Box::new(|route| route.worktree_mode = "shared_repository".to_string()), + ), + ( + "workflow_route_context_ceiling_exceeded", + Box::new(|route| route.context_ceiling_tokens = Some(128_001)), + ), ]; for (expected_code, mutate) in cases { let mut effective = effective_route(); mutate(&mut effective); - let error = admit_workflow_model_route(&exact_route(), &effective) + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission( + &exact_route(), + &effective, + &provider_calls, + ) .expect_err("mismatched route must fail closed"); assert_eq!(error.code(), expected_code); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn unavailable_required_runtime_dimensions_fail_before_provider_attempt() { + let cases: Vec<(&str, Box)> = vec![ + ( + "workflow_route_gateway_unavailable", + Box::new(|runtime| runtime.model_gateway = None), + ), + ( + "workflow_route_provider_unavailable", + Box::new(|runtime| runtime.provider = None), + ), + ( + "workflow_route_model_unavailable", + Box::new(|runtime| runtime.model = None), + ), + ( + "workflow_route_reasoning_unavailable", + Box::new(|runtime| runtime.reasoning = None), + ), + ( + "workflow_route_context_ceiling_unavailable", + Box::new(|runtime| runtime.context_window_tokens = None), + ), + ]; + + for (expected_code, mutate) in cases { + let mut runtime = runtime(); + mutate(&mut runtime); + let error = admit_workflow_model_route_for_runtime( + &exact_route(), + &runtime, + "isolated_worktree", + ) + .expect_err("unavailable runtime dimension must fail closed"); + assert_eq!(error.code(), expected_code); } } @@ -776,9 +887,15 @@ mod tests { .constraints .fallback_required = true; - let error = admit_workflow_model_route(&route, &effective_route()) + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission( + &route, + &effective_route(), + &provider_calls, + ) .expect_err("required fallback cannot be ignored"); assert_eq!(error.code(), "workflow_route_required_fallback_missing"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } #[test] @@ -794,9 +911,11 @@ mod tests { let mut effective = effective_route(); effective.credit_control = WorkflowProviderCreditControl::Unavailable; - let error = admit_workflow_model_route(&route, &effective) + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) .expect_err("finite budgets require a provider reservation"); assert_eq!(error.code(), "workflow_route_credit_ceiling_unavailable"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } #[test] @@ -818,9 +937,11 @@ mod tests { exhausted: true, }; - let error = admit_workflow_model_route(&route, &effective) + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) .expect_err("an exhausted reservation cannot admit provider work"); assert_eq!(error.code(), "workflow_route_credit_ceiling_exhausted"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } #[test] From 76d454b0ff47d5b139139e98f4c6aa057d373956 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:08:24 +0300 Subject: [PATCH 04/30] feat(core): enforce workflow route per provider call Agent: Nausicaa --- codex-rs/Cargo.lock | 1 + .../background_agent_live.rs | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/client.rs | 67 +++++++++- codex-rs/core/src/client_tests.rs | 66 ++++++++++ codex-rs/core/src/config/mod.rs | 5 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 1 + codex-rs/workflows/src/routing_enforcement.rs | 116 ++++++++++++++++-- 9 files changed, 245 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fb614e0ac..508cd517d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2548,6 +2548,7 @@ dependencies = [ "codex-utils-string", "codex-web-search-extension", "codex-windows-sandbox", + "codex-workflows", "core_test_support", "croner", "csv", diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index 9ff32c574..ee9e23584 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -4055,6 +4055,7 @@ fn enforce_workflow_route_config( credit_control: WorkflowProviderCreditControl::NotRequested, }; receipt.enforce_provider_attempt(&effective)?; + config.workflow_route_receipt = Some(receipt); Ok(()) } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 0c2c6d704..dcc6707be 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -68,6 +68,7 @@ codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-thread-store = { workspace = true } codex-tools = { workspace = true } +codex-workflows = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d08dfeb3b..e2ba71b75 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -88,6 +88,7 @@ use codex_rollout_trace::CompactionTraceContext; use codex_rollout_trace::InferenceTraceAttempt; use codex_rollout_trace::InferenceTraceContext; use codex_tools::create_tools_json_for_responses_api; +use codex_workflows::WorkflowRouteReceipt; use eventsource_stream::Event; use eventsource_stream::EventStreamError; use futures::StreamExt; @@ -180,6 +181,7 @@ struct ModelClientState { window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, + provider_id: String, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, parent_thread_id: Option, @@ -229,6 +231,7 @@ impl RequestRouteTelemetry { pub struct ModelClient { state: Arc, prompt_cache_key_override: Option, + workflow_route_receipt: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -410,7 +413,7 @@ impl ModelClient { attestation_provider: Option>, ) -> Self { let model_provider = - create_model_provider_with_id(provider_id, provider_info, auth_manager); + create_model_provider_with_id(provider_id.clone(), provider_info, auth_manager); let codex_api_key_env_enabled = model_provider .auth_manager() .as_ref() @@ -425,6 +428,7 @@ impl ModelClient { window_generation: AtomicU64::new(0), installation_id, provider: model_provider, + provider_id, auth_env_telemetry, session_source, parent_thread_id, @@ -438,6 +442,7 @@ impl ModelClient { cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), prompt_cache_key_override: None, + workflow_route_receipt: None, } } @@ -449,6 +454,38 @@ impl ModelClient { self } + pub(crate) fn with_workflow_route_receipt( + mut self, + workflow_route_receipt: Option, + ) -> Self { + self.workflow_route_receipt = workflow_route_receipt; + self + } + + fn enforce_workflow_provider_attempt( + &self, + model_info: &ModelInfo, + effort: Option<&ReasoningEffortConfig>, + service_tier: Option<&str>, + ) -> Result<()> { + let Some(receipt) = self.workflow_route_receipt.as_ref() else { + return Ok(()); + }; + let mut effective = receipt.effective.clone(); + effective.provider = self.state.provider_id.clone(); + effective.model = model_info.slug.clone(); + effective.reasoning = effort.map(ToString::to_string).ok_or_else(|| { + CodexErr::InvalidRequest( + "workflow_route_reasoning_unavailable: provider request has no reasoning effort" + .to_string(), + ) + })?; + effective.service_tier = service_tier.map(str::to_string); + receipt + .enforce_provider_attempt(&effective) + .map_err(|error| CodexErr::InvalidRequest(error.to_string())) + } + fn prompt_cache_key(&self) -> String { self.prompt_cache_key_override .clone() @@ -560,6 +597,11 @@ impl ModelClient { if prompt.input.is_empty() { return Ok(Vec::new()); } + self.enforce_workflow_provider_attempt( + model_info, + settings.effort.as_ref(), + settings.service_tier.as_deref(), + )?; let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = @@ -653,6 +695,12 @@ impl ModelClient { ) -> Result { // Create the media call over HTTP first, then retain matching auth so realtime can attach // the server-side control WebSocket to the call id from that HTTP response. + if self.workflow_route_receipt.is_some() { + return Err(CodexErr::UnsupportedOperation( + "workflow_route_provider_call_unsupported: realtime calls are not part of the admitted workflow route" + .to_string(), + )); + } let client_setup = self.current_client_setup().await?; if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); @@ -691,6 +739,8 @@ impl ModelClient { return Ok(Vec::new()); } + self.enforce_workflow_provider_attempt(model_info, effort.as_ref(), None)?; + let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); let request_telemetry = Self::build_request_telemetry( @@ -1452,6 +1502,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; ensure_remote_compaction_request_budget_available(request_budget)?; let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -1577,6 +1632,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; ensure_remote_compaction_request_budget_available(request_budget)?; let client_setup = self.client.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -1706,6 +1766,11 @@ impl ModelClientSession { .map(AuthManager::unauthorized_recovery); let mut pending_retry = PendingUnauthorizedRetry::default(); loop { + self.client.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + service_tier.as_deref(), + )?; let client_setup = self.client.current_client_setup().await?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 124a4a60b..f1b6f42a6 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -30,6 +30,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -47,6 +48,10 @@ use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; use codex_tools::ZaiWebSearchConfig; +use codex_workflows::WorkflowEffectiveModelRoute; +use codex_workflows::WorkflowModelRoute; +use codex_workflows::WorkflowProviderCreditControl; +use codex_workflows::WorkflowRouteReceipt; use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; @@ -148,6 +153,67 @@ fn test_model_info() -> ModelInfo { .expect("deserialize test model info") } +fn workflow_route_receipt_for_client(client: &ModelClient) -> WorkflowRouteReceipt { + let provider = client.state.provider_id.clone(); + let requested = WorkflowModelRoute { + model_gateway: "direct".to_string(), + provider: provider.clone(), + model: "gpt-test".to_string(), + reasoning: "medium".to_string(), + service_tier: None, + approval_policy: None, + permission_profile: None, + routing: None, + }; + WorkflowRouteReceipt { + requested, + effective: WorkflowEffectiveModelRoute { + model_gateway: "direct".to_string(), + provider, + model: "gpt-test".to_string(), + reasoning: "medium".to_string(), + service_tier: None, + auth_profile: None, + approval_policy: None, + permission_profile: None, + worktree_mode: "shared_repository".to_string(), + context_ceiling_tokens: None, + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + }, + } +} + +#[test] +fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { + let client = test_model_client(SessionSource::Exec); + let receipt = workflow_route_receipt_for_client(&client); + let client = client.with_workflow_route_receipt(Some(receipt)); + let provider_calls = AtomicUsize::new(0); + + client + .enforce_workflow_provider_attempt( + &test_model_info(), + Some(&ReasoningEffort::Medium), + None, + ) + .expect("the exact admitted request must pass"); + provider_calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + + let mut mismatched_model = test_model_info(); + mismatched_model.slug = "gpt-other".to_string(); + let error = client + .enforce_workflow_provider_attempt( + &mismatched_model, + Some(&ReasoningEffort::Medium), + None, + ) + .expect_err("a mismatched provider request must fail before the call"); + assert!(error.to_string().contains("workflow_route_model_mismatch")); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); +} + fn test_session_telemetry() -> SessionTelemetry { SessionTelemetry::new( ThreadId::new(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 964bd5337..a2a9fffa0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1242,6 +1242,10 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Immutable workflow route contract for provider-call enforcement. + /// Set only by the admitted workflow worker path after snapshot revalidation. + pub workflow_route_receipt: Option, + /// Optionally specify the personality of the model pub personality: Option, @@ -4654,6 +4658,7 @@ impl Config { model_provider_id, model_gateway_id, model_provider, + workflow_route_receipt: None, cwd: resolved_cwd, workspace_roots: workspace_roots.clone(), workspace_roots_explicit, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ca47b23fc..9071ca8c0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1089,6 +1089,7 @@ impl Session { Self::build_model_client_beta_features_header(config), self.services.attestation_provider.clone(), ) + .with_workflow_route_receipt(config.workflow_route_receipt.clone()) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6e3c9d971..c7d99e051 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1204,6 +1204,7 @@ impl Session { Self::build_model_client_beta_features_header(config.as_ref()), attestation_provider, ) + .with_workflow_route_receipt(config.workflow_route_receipt.clone()) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index 309cda3ef..f8d1df65f 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -495,18 +495,31 @@ fn enforce_credit_control( ( Some(budget_usd), WorkflowProviderCreditControl::Reserved { + reservation_id, ceiling_usd, + spent_usd, remaining_usd, exhausted, - .. }, ) => { + if reservation_id.trim().is_empty() { + return Err(route_error( + "workflow_route_credit_accounting_invalid", + "provider reservation id is missing", + )); + } if ceiling_usd != budget_usd { return Err(route_error( "workflow_route_credit_ceiling_mismatch", "provider reservation ceiling differs from the requested budget", )); } + if !credit_accounting_balances(ceiling_usd, spent_usd, remaining_usd) { + return Err(route_error( + "workflow_route_credit_accounting_invalid", + "provider credit readback does not balance to the reserved ceiling", + )); + } if *exhausted || decimal_is_zero(remaining_usd) { return Err(route_error( "workflow_route_credit_ceiling_exhausted", @@ -540,6 +553,62 @@ fn decimal_is_zero(value: &str) -> bool { .all(|character| character == '0') } +fn credit_accounting_balances(ceiling: &str, spent: &str, remaining: &str) -> bool { + let Some(scale) = [ceiling, spent, remaining] + .into_iter() + .map(decimal_scale) + .collect::>>() + .and_then(|scales| scales.into_iter().max()) + else { + return false; + }; + let Some(ceiling) = decimal_at_scale(ceiling, scale) else { + return false; + }; + let Some(spent) = decimal_at_scale(spent, scale) else { + return false; + }; + let Some(remaining) = decimal_at_scale(remaining, scale) else { + return false; + }; + spent + .checked_add(remaining) + .is_some_and(|total| total == ceiling) +} + +fn decimal_scale(value: &str) -> Option { + let value = value.trim(); + let mut parts = value.split('.'); + let whole = parts.next()?; + let fraction = parts.next(); + if parts.next().is_some() + || whole.is_empty() + || !whole.chars().all(|character| character.is_ascii_digit()) + || fraction.is_some_and(|fraction| { + fraction.is_empty() + || !fraction + .chars() + .all(|character| character.is_ascii_digit()) + }) + { + return None; + } + Some(fraction.map_or(0, str::len)) +} + +fn decimal_at_scale(value: &str, scale: usize) -> Option { + let value = value.trim(); + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > scale { + return None; + } + let mut digits = String::with_capacity(whole.len().saturating_add(scale)); + digits.push_str(whole); + digits.push_str(fraction); + digits.extend(std::iter::repeat_n('0', scale.saturating_sub(fraction.len()))); + digits.parse::().ok() +} + fn enforce_exact( code: &'static str, field: &str, @@ -827,12 +896,9 @@ mod tests { let mut effective = effective_route(); mutate(&mut effective); let provider_calls = AtomicUsize::new(0); - let error = invoke_provider_after_route_admission( - &exact_route(), - &effective, - &provider_calls, - ) - .expect_err("mismatched route must fail closed"); + let error = + invoke_provider_after_route_admission(&exact_route(), &effective, &provider_calls) + .expect_err("mismatched route must fail closed"); assert_eq!(error.code(), expected_code); assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } @@ -888,12 +954,9 @@ mod tests { .fallback_required = true; let provider_calls = AtomicUsize::new(0); - let error = invoke_provider_after_route_admission( - &route, - &effective_route(), - &provider_calls, - ) - .expect_err("required fallback cannot be ignored"); + let error = + invoke_provider_after_route_admission(&route, &effective_route(), &provider_calls) + .expect_err("required fallback cannot be ignored"); assert_eq!(error.code(), "workflow_route_required_fallback_missing"); assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } @@ -976,4 +1039,31 @@ mod tests { } ); } + + #[test] + fn inconsistent_provider_credit_readback_fails_closed() { + let mut route = exact_route(); + route + .routing + .as_mut() + .expect("routing contract") + .request + .constraints + .budget_usd = Some("5.00".to_string()); + let mut effective = effective_route(); + effective.credit_control = WorkflowProviderCreditControl::Reserved { + reservation_id: "reservation-1".to_string(), + ceiling_usd: "5.00".to_string(), + spent_usd: "1.25".to_string(), + remaining_usd: "4.00".to_string(), + exhausted: false, + }; + let provider_calls = AtomicUsize::new(0); + + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("unbalanced provider accounting cannot admit work"); + + assert_eq!(error.code(), "workflow_route_credit_accounting_invalid"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } } From 9d4df7b6732925e8912a05b9e9fbaa82bb449b76 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:12:05 +0300 Subject: [PATCH 05/30] style(workflows): apply hosted formatting Agent: Nausicaa --- codex-rs/core/src/client_tests.rs | 12 ++---------- codex-rs/workflows/src/routing_enforcement.rs | 10 +++++----- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f1b6f42a6..fc0292fda 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -192,11 +192,7 @@ fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { let provider_calls = AtomicUsize::new(0); client - .enforce_workflow_provider_attempt( - &test_model_info(), - Some(&ReasoningEffort::Medium), - None, - ) + .enforce_workflow_provider_attempt(&test_model_info(), Some(&ReasoningEffort::Medium), None) .expect("the exact admitted request must pass"); provider_calls.fetch_add(1, Ordering::SeqCst); assert_eq!(provider_calls.load(Ordering::SeqCst), 1); @@ -204,11 +200,7 @@ fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { let mut mismatched_model = test_model_info(); mismatched_model.slug = "gpt-other".to_string(); let error = client - .enforce_workflow_provider_attempt( - &mismatched_model, - Some(&ReasoningEffort::Medium), - None, - ) + .enforce_workflow_provider_attempt(&mismatched_model, Some(&ReasoningEffort::Medium), None) .expect_err("a mismatched provider request must fail before the call"); assert!(error.to_string().contains("workflow_route_model_mismatch")); assert_eq!(provider_calls.load(Ordering::SeqCst), 1); diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index f8d1df65f..b3d500834 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -585,10 +585,7 @@ fn decimal_scale(value: &str) -> Option { || whole.is_empty() || !whole.chars().all(|character| character.is_ascii_digit()) || fraction.is_some_and(|fraction| { - fraction.is_empty() - || !fraction - .chars() - .all(|character| character.is_ascii_digit()) + fraction.is_empty() || !fraction.chars().all(|character| character.is_ascii_digit()) }) { return None; @@ -605,7 +602,10 @@ fn decimal_at_scale(value: &str, scale: usize) -> Option { let mut digits = String::with_capacity(whole.len().saturating_add(scale)); digits.push_str(whole); digits.push_str(fraction); - digits.extend(std::iter::repeat_n('0', scale.saturating_sub(fraction.len()))); + digits.extend(std::iter::repeat_n( + '0', + scale.saturating_sub(fraction.len()), + )); digits.parse::().ok() } From 3eb3f5147cef06e80f5be4c79302d69e8e26d2a1 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:15:20 +0300 Subject: [PATCH 06/30] fix(state): import workflow route attestation Agent: Nausicaa --- codex-rs/state/src/runtime/workflow_orchestrator.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 67775eadf..0f8ff745e 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1,5 +1,6 @@ use super::*; use crate::runtime::background_agents::ExistingBackgroundAgentAdmissionIdentity; +use crate::BackgroundAgentModelAttestationCreateParams; use crate::runtime::background_agents::append_background_agent_event_in_tx; use crate::runtime::background_agents::background_agent_admission_identity_sha256; use crate::runtime::background_agents::background_agent_idempotency_key_digest; From bbd475a56a99ff207fa0aa583b8f5d84ca42dbc3 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:17:47 +0300 Subject: [PATCH 07/30] style(state): order route attestation import Agent: Nausicaa --- codex-rs/state/src/runtime/workflow_orchestrator.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 0f8ff745e..61aa8cccc 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1,6 +1,6 @@ use super::*; -use crate::runtime::background_agents::ExistingBackgroundAgentAdmissionIdentity; use crate::BackgroundAgentModelAttestationCreateParams; +use crate::runtime::background_agents::ExistingBackgroundAgentAdmissionIdentity; use crate::runtime::background_agents::append_background_agent_event_in_tx; use crate::runtime::background_agents::background_agent_admission_identity_sha256; use crate::runtime::background_agents::background_agent_idempotency_key_digest; From 39fd4483a5f6e8b40162fa80dd088de768a26a3d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:22:05 +0300 Subject: [PATCH 08/30] docs(workflows): describe enforced route contract Agent: Nausicaa --- codex-rs/workflows/src/spec.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/workflows/src/spec.rs b/codex-rs/workflows/src/spec.rs index e0ea8d485..8197a98a4 100644 --- a/codex-rs/workflows/src/spec.rs +++ b/codex-rs/workflows/src/spec.rs @@ -65,11 +65,11 @@ pub struct WorkflowModelRoute { pub routing: Option, } -/// Policy-free contract that a future open-router integration can satisfy. +/// Policy-free routing contract enforced by workflow activation and provider attempts. /// -/// The surrounding [`WorkflowModelRoute`] remains exact and executable today. -/// This contract only records the request/decision boundary so Codewith -/// workflow specs can be validated without embedding provider ranking policy. +/// The surrounding [`WorkflowModelRoute`] and requested-versus-effective receipt stay exact +/// across retries, recovery, descendants, and verifier attempts. Routing policy remains outside +/// the spec, while unsupported enforcement surfaces fail closed before external effects. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WorkflowModelRoutingContract { From cbf3fa8bea81fe7142a43c3b4c6966d204eb3bbb Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:30:57 +0300 Subject: [PATCH 09/30] fix(workflows): preserve routes for descendants Agent: Nausicaa --- codex-rs/app-server/README.md | 2 +- .../background_agent_live.rs | 110 ++++++++++-- .../background_agent_processor.rs | 161 ++++++++++++++++-- 3 files changed, 250 insertions(+), 23 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index bf2423555..45259c2c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1154,7 +1154,7 @@ Use `thread/workflow/get` to read one saved workflow metadata record, or `thread Use `thread/workflow/run/start` to start a saved workflow for the same thread. The response includes sanitized run state and the projected goal plan when projection succeeds. Use run list/get for inspection and pause/resume/cancel for lifecycle control. -Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work rather than applying a caller estimate after launch. +Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. An `agent/start` request whose `parentAgentRunId` identifies a workflow worker inherits that exact receipt, must use an isolated worktree when the receipt requires one, and is rejected before admission if the configured descendant route differs. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work rather than applying a caller estimate after launch. ```json { "method": "thread/workflow/run/start", "id": 36, "params": { diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index ee9e23584..3fe654833 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -387,6 +387,12 @@ impl ThreadRequestProcessor { })?); } } + let inherited_workflow_route = self + .workflow_descendant_route_receipt( + params.parent_agent_run_id.as_deref(), + managed_worktree.as_ref(), + ) + .await?; let response = self .background_agent_state_processor() .agent_start_inner( @@ -394,6 +400,7 @@ impl ThreadRequestProcessor { managed_worktree .as_ref() .map(|worktree| worktree.worktree_id.as_str()), + inherited_workflow_route.as_ref(), ) .await?; if let Some(worktree) = managed_worktree.as_ref() { @@ -1802,6 +1809,54 @@ impl ThreadRequestProcessor { params.auth_profile_ref = self.config.selected_auth_profile.clone(); } + async fn workflow_descendant_route_receipt( + &self, + parent_agent_run_id: Option<&str>, + managed_worktree: Option<&AgentStartManagedWorktree>, + ) -> Result, JSONRPCErrorError> { + let Some(parent_agent_run_id) = parent_agent_run_id else { + return Ok(None); + }; + let state_db = self + .state_db + .as_ref() + .ok_or_else(|| internal_error("background agent state store is unavailable"))?; + let parent = state_db + .get_background_agent_run(parent_agent_run_id) + .await + .map_err(|err| internal_error(format!("failed to load parent agent run: {err}")))? + .ok_or_else(|| invalid_params("parentAgentRunId does not identify an admitted run"))?; + let snapshot = state_db + .get_background_agent_initial_execution_snapshot(parent_agent_run_id) + .await + .map_err(|err| { + internal_error(format!("failed to load parent agent execution snapshot: {err}")) + })? + .ok_or_else(|| { + invalid_params("parentAgentRunId has no initial execution snapshot") + })?; + let Some(receipt) = validate_workflow_route_snapshot(&parent, &snapshot) + .map_err(workflow_route_admission_error)? + else { + return Ok(None); + }; + let worktree_mode = if managed_worktree.is_some() { + "isolated" + } else { + "in_place" + }; + let effective = effective_workflow_route_from_config( + self.config.as_ref(), + &receipt, + worktree_mode, + ) + .map_err(workflow_route_admission_error)?; + receipt + .enforce_descendant(&effective) + .map_err(workflow_route_admission_error)?; + Ok(Some(receipt)) + } + fn spawn_background_agent_reconcile(&self, only_run_id: Option) { let Some(context) = self.background_agent_worker_context() else { return; @@ -3918,7 +3973,7 @@ fn validate_workflow_route_snapshot( run: &BackgroundAgentRun, snapshot: &BackgroundAgentExecutionSnapshot, ) -> anyhow::Result> { - if run.source != "workflow" { + if run.source != "workflow" && run.source != "workflow-descendant" { return Ok(None); } let receipt_value = snapshot.payload_json.get("routeReceipt").ok_or_else(|| { @@ -4025,7 +4080,32 @@ fn enforce_workflow_route_config( .model_auto_compact_token_limit .map(|limit| limit.min(context_ceiling_tokens)); } - let effective = WorkflowEffectiveModelRoute { + let effective = effective_workflow_route_from_config( + config, + &receipt, + receipt.effective.worktree_mode.as_str(), + )?; + receipt.enforce_provider_attempt(&effective)?; + config.workflow_route_receipt = Some(receipt); + Ok(()) +} + +fn effective_workflow_route_from_config( + config: &codex_core::config::Config, + receipt: &WorkflowRouteReceipt, + worktree_mode: &str, +) -> anyhow::Result { + let configured_context_window = config + .model_context_window + .and_then(|tokens| u64::try_from(tokens).ok()); + if let Some(required_ceiling) = receipt.effective.context_ceiling_tokens + && configured_context_window.is_none_or(|available| available < required_ceiling) + { + anyhow::bail!( + "workflow_route_context_ceiling_unavailable: configured model context is smaller than the inherited ceiling" + ); + } + Ok(WorkflowEffectiveModelRoute { model_gateway: config.model_gateway_id.clone(), provider: config.model_provider_id.clone(), model: config.model.clone().ok_or_else(|| { @@ -4047,16 +4127,26 @@ fn enforce_workflow_route_config( .permissions .active_permission_profile() .map(|profile| profile.id), - worktree_mode: receipt.effective.worktree_mode.clone(), - context_ceiling_tokens: config - .model_context_window - .and_then(|tokens| u64::try_from(tokens).ok()), + worktree_mode: worktree_mode.to_string(), + context_ceiling_tokens: receipt.effective.context_ceiling_tokens, fallback_used: receipt.effective.fallback_used, credit_control: WorkflowProviderCreditControl::NotRequested, - }; - receipt.enforce_provider_attempt(&effective)?; - config.workflow_route_receipt = Some(receipt); - Ok(()) + }) +} + +fn workflow_route_admission_error(err: impl std::fmt::Display) -> JSONRPCErrorError { + let message = err.to_string(); + let code = message + .split(':') + .next() + .unwrap_or("workflow_route_invalid") + .to_string(); + let mut error = invalid_request(message); + error.data = Some(json!({ + "errorCode": code, + "retriable": false, + })); + error } async fn insert_initial_goal_for_background_thread( diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 3da00afb3..596a5b4af 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -95,6 +95,7 @@ use codex_rollout::StateDbHandle; use codex_state::ManagedWorktreeAssignmentTarget; use codex_state::ManagedWorktreeAttachParams; use codex_state::ManagedWorktreeDetachParams; +use codex_workflows::WorkflowRouteReceipt; use serde_json::json; use sha2::Digest; use sha2::Sha256; @@ -122,6 +123,7 @@ impl BackgroundAgentRequestProcessor { &self, mut params: AgentStartParams, required_managed_worktree_id: Option<&str>, + inherited_workflow_route: Option<&WorkflowRouteReceipt>, ) -> Result { let state_db = self.state_db()?; normalize_agent_start_schema(&mut params)?; @@ -181,7 +183,11 @@ impl BackgroundAgentRequestProcessor { .unwrap_or_else(|| agent_id.clone()); format!("inline:{identity}:prompt") }); - let source = source.unwrap_or_else(|| "app-server".to_string()); + let source = if inherited_workflow_route.is_some() { + "workflow-descendant".to_string() + } else { + source.unwrap_or_else(|| "app-server".to_string()) + }; let thread_store_kind = thread_store_kind.unwrap_or_else(|| "background-agent".to_string()); validate_agent_start_rollout_path( state_db.as_ref(), @@ -201,6 +207,7 @@ impl BackgroundAgentRequestProcessor { initial_goal_objective.as_deref(), auth_profile_ref.as_deref(), execution_context.as_ref(), + inherited_workflow_route, ) .map_err(|err| { internal_error(format!( @@ -219,6 +226,7 @@ impl BackgroundAgentRequestProcessor { required_managed_worktree_id, config_fingerprint: config_fingerprint.as_deref(), version_fingerprint: version_fingerprint.as_deref(), + inherited_workflow_route, }); let requested_model = execution_context .as_ref() @@ -245,15 +253,30 @@ impl BackgroundAgentRequestProcessor { status_reason: Some("queued for background-agent supervisor".to_string()), config_fingerprint: config_fingerprint.clone(), version_fingerprint, - model_attestation: Some(BackgroundAgentModelAttestationCreateParams { - requested_model, - requested_configuration: json!({ - "executionContext": execution_context.as_ref(), - }), - applied_model, - applied_configuration: json!({ - "executionContext": execution_payload, - }), + model_attestation: Some(if let Some(receipt) = inherited_workflow_route { + BackgroundAgentModelAttestationCreateParams { + requested_model: Some(receipt.requested.model.clone()), + requested_configuration: json!({ + "executionContext": execution_context.as_ref(), + "routeReceipt": receipt, + }), + applied_model: Some(receipt.effective.model.clone()), + applied_configuration: json!({ + "executionContext": execution_payload, + "routeReceipt": receipt, + }), + } + } else { + BackgroundAgentModelAttestationCreateParams { + requested_model, + requested_configuration: json!({ + "executionContext": execution_context.as_ref(), + }), + applied_model, + applied_configuration: json!({ + "executionContext": execution_payload, + }), + } }), }; let prompt_sha256 = format!("{:x}", Sha256::digest(prompt.as_bytes())); @@ -1130,12 +1153,13 @@ struct InitialExecutionSnapshotPayloadParams<'a> { required_managed_worktree_id: Option<&'a str>, config_fingerprint: Option<&'a str>, version_fingerprint: Option<&'a str>, + inherited_workflow_route: Option<&'a WorkflowRouteReceipt>, } fn initial_execution_snapshot_payload( params: InitialExecutionSnapshotPayloadParams<'_>, ) -> serde_json::Value { - json!({ + let mut payload = json!({ "snapshotSource": "agent/start", "cwd": params.cwd, "initialGoalObjective": params.initial_goal_objective, @@ -1191,7 +1215,45 @@ fn initial_execution_snapshot_payload( "packageFingerprint": BACKGROUND_AGENT_RUNTIME_COMPATIBILITY_FINGERPRINT, "recoveryPolicy": params.recovery_policy, "midTurnCrashSemantics": "abort_mid_turn_resume_at_safe_boundary", - }) + }); + if let Some(receipt) = params.inherited_workflow_route { + let payload = payload + .as_object_mut() + .expect("initial execution snapshot payload is an object"); + payload.insert( + "modelGateway".to_string(), + json!(receipt.effective.model_gateway), + ); + payload.insert("provider".to_string(), json!(receipt.effective.provider)); + payload.insert("model".to_string(), json!(receipt.effective.model)); + payload.insert("reasoning".to_string(), json!(receipt.effective.reasoning)); + payload.insert( + "serviceTier".to_string(), + json!(receipt.effective.service_tier), + ); + payload.insert( + "approvalPolicy".to_string(), + json!(receipt.effective.approval_policy), + ); + payload.insert( + "permissionProfile".to_string(), + json!(receipt.effective.permission_profile), + ); + payload.insert("routeReceipt".to_string(), json!(receipt)); + payload.insert( + "contextCeilingTokens".to_string(), + json!(receipt.effective.context_ceiling_tokens), + ); + payload.insert( + "creditAccounting".to_string(), + json!(receipt.terminal_credit_accounting()), + ); + payload.insert( + "workspace".to_string(), + json!({"mode": receipt.effective.worktree_mode}), + ); + } + payload } fn background_agent_config_fingerprint( @@ -1199,12 +1261,14 @@ fn background_agent_config_fingerprint( initial_goal_objective: Option<&str>, auth_profile_ref: Option<&str>, execution_context: Option<&AgentExecutionContextParams>, + inherited_workflow_route: Option<&WorkflowRouteReceipt>, ) -> anyhow::Result { let config_identity = json!({ "cwd": cwd, "initialGoalObjective": initial_goal_objective, "authProfileRef": auth_profile_ref, "executionContext": execution_context, + "routeReceipt": inherited_workflow_route, }); Ok(format!( "{:x}", @@ -1832,8 +1896,81 @@ fn api_lifecycle_effect_from_runtime(effect: LifecycleEffect) -> AgentLifecycleE mod tests { use super::*; use chrono::Utc; + use codex_workflows::WorkflowEffectiveModelRoute; + use codex_workflows::WorkflowModelRoute; + use codex_workflows::WorkflowProviderCreditControl; use tempfile::TempDir; + #[test] + fn descendant_snapshot_persists_the_immutable_parent_route_receipt() { + let receipt = WorkflowRouteReceipt { + requested: WorkflowModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some("workspace-write".to_string()), + routing: None, + }, + effective: WorkflowEffectiveModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some("workspace-write".to_string()), + worktree_mode: "isolated".to_string(), + context_ceiling_tokens: Some(128_000), + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + }, + }; + let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { + cwd: Some("/tmp/worktree"), + initial_goal_objective: None, + execution_context: None, + recovery_policy: "abort_mid_turn_resume_at_safe_boundary", + auth_profile_ref: Some("account007"), + required_managed_worktree_id: Some("worktree-1"), + config_fingerprint: Some("cfg-1"), + version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), + inherited_workflow_route: Some(&receipt), + }); + + assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); + assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); + assert_eq!(payload.get("reasoning"), Some(&json!("high"))); + assert_eq!( + payload.pointer("/workspace/mode"), + Some(&json!("isolated")) + ); + assert_eq!( + payload.get("creditAccounting"), + Some(&json!({"status": "not_requested"})) + ); + } + + #[test] + fn ordinary_agent_snapshot_does_not_claim_a_workflow_route_receipt() { + let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { + cwd: None, + initial_goal_objective: None, + execution_context: None, + recovery_policy: "abort_mid_turn_resume_at_safe_boundary", + auth_profile_ref: None, + required_managed_worktree_id: None, + config_fingerprint: Some("cfg-1"), + version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), + inherited_workflow_route: None, + }); + + assert_eq!(payload.get("routeReceipt"), None); + } + #[tokio::test] async fn agent_stop_with_active_pending_interaction_keeps_snapshot_in_sync() -> anyhow::Result<()> { From 509dfe60b17583deac131a2c2108333bc5e57575 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:34:00 +0300 Subject: [PATCH 10/30] test(workflows): cover descendant admission Agent: Nausicaa --- .../background_agent_live.rs | 17 ++- .../background_agent_processor.rs | 108 +++++++++++++----- 2 files changed, 85 insertions(+), 40 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index 3fe654833..3fd7a50a5 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -1830,11 +1830,11 @@ impl ThreadRequestProcessor { .get_background_agent_initial_execution_snapshot(parent_agent_run_id) .await .map_err(|err| { - internal_error(format!("failed to load parent agent execution snapshot: {err}")) + internal_error(format!( + "failed to load parent agent execution snapshot: {err}" + )) })? - .ok_or_else(|| { - invalid_params("parentAgentRunId has no initial execution snapshot") - })?; + .ok_or_else(|| invalid_params("parentAgentRunId has no initial execution snapshot"))?; let Some(receipt) = validate_workflow_route_snapshot(&parent, &snapshot) .map_err(workflow_route_admission_error)? else { @@ -1845,12 +1845,9 @@ impl ThreadRequestProcessor { } else { "in_place" }; - let effective = effective_workflow_route_from_config( - self.config.as_ref(), - &receipt, - worktree_mode, - ) - .map_err(workflow_route_admission_error)?; + let effective = + effective_workflow_route_from_config(self.config.as_ref(), &receipt, worktree_mode) + .map_err(workflow_route_admission_error)?; receipt .enforce_descendant(&effective) .map_err(workflow_route_admission_error)?; diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 596a5b4af..a6c80ede2 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -1903,32 +1903,7 @@ mod tests { #[test] fn descendant_snapshot_persists_the_immutable_parent_route_receipt() { - let receipt = WorkflowRouteReceipt { - requested: WorkflowModelRoute { - model_gateway: "openrouter".to_string(), - provider: "openrouter".to_string(), - model: "openai/gpt-5.6-sol".to_string(), - reasoning: "high".to_string(), - service_tier: Some("priority".to_string()), - approval_policy: Some("never".to_string()), - permission_profile: Some("workspace-write".to_string()), - routing: None, - }, - effective: WorkflowEffectiveModelRoute { - model_gateway: "openrouter".to_string(), - provider: "openrouter".to_string(), - model: "openai/gpt-5.6-sol".to_string(), - reasoning: "high".to_string(), - service_tier: Some("priority".to_string()), - auth_profile: Some("account007".to_string()), - approval_policy: Some("never".to_string()), - permission_profile: Some("workspace-write".to_string()), - worktree_mode: "isolated".to_string(), - context_ceiling_tokens: Some(128_000), - fallback_used: false, - credit_control: WorkflowProviderCreditControl::NotRequested, - }, - }; + let receipt = test_workflow_route_receipt(); let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { cwd: Some("/tmp/worktree"), initial_goal_objective: None, @@ -1944,10 +1919,7 @@ mod tests { assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); assert_eq!(payload.get("reasoning"), Some(&json!("high"))); - assert_eq!( - payload.pointer("/workspace/mode"), - Some(&json!("isolated")) - ); + assert_eq!(payload.pointer("/workspace/mode"), Some(&json!("isolated"))); assert_eq!( payload.get("creditAccounting"), Some(&json!({"status": "not_requested"})) @@ -1971,6 +1943,82 @@ mod tests { assert_eq!(payload.get("routeReceipt"), None); } + #[tokio::test] + async fn descendant_admission_persists_the_parent_route_and_source() -> anyhow::Result<()> { + let (_temp, state_db) = temp_state_db().await?; + let processor = BackgroundAgentRequestProcessor::new(Some(state_db)); + let receipt = test_workflow_route_receipt(); + let response = processor + .agent_start_inner( + AgentStartParams { + prompt: "continue the workflow child".to_string(), + initial_goal_objective: None, + cwd: Some("/tmp/worktree".to_string()), + idempotency_key: Some("workflow-child-1".to_string()), + request_id: None, + source: Some("caller-supplied".to_string()), + prompt_snapshot_ref: None, + input_snapshot_ref: None, + thread_id: None, + thread_store_kind: None, + thread_store_id: None, + rollout_path: None, + parent_thread_id: None, + parent_agent_run_id: Some("workflow-parent-1".to_string()), + spawn_linkage: None, + auth_profile_ref: Some("account007".to_string()), + config_fingerprint: None, + version_fingerprint: Some( + BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION.to_string(), + ), + execution_context: None, + }, + Some("worktree-1"), + Some(&receipt), + ) + .await?; + + assert_eq!(response.agent.source, "workflow-descendant"); + assert_eq!( + response.agent.parent_agent_run_id.as_deref(), + Some("workflow-parent-1") + ); + assert_eq!( + response.execution_snapshot.payload.get("routeReceipt"), + Some(&json!(receipt)) + ); + Ok(()) + } + + fn test_workflow_route_receipt() -> WorkflowRouteReceipt { + WorkflowRouteReceipt { + requested: WorkflowModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some("workspace-write".to_string()), + routing: None, + }, + effective: WorkflowEffectiveModelRoute { + model_gateway: "openrouter".to_string(), + provider: "openrouter".to_string(), + model: "openai/gpt-5.6-sol".to_string(), + reasoning: "high".to_string(), + service_tier: Some("priority".to_string()), + auth_profile: Some("account007".to_string()), + approval_policy: Some("never".to_string()), + permission_profile: Some("workspace-write".to_string()), + worktree_mode: "isolated".to_string(), + context_ceiling_tokens: Some(128_000), + fallback_used: false, + credit_control: WorkflowProviderCreditControl::NotRequested, + }, + } + } + #[tokio::test] async fn agent_stop_with_active_pending_interaction_keeps_snapshot_in_sync() -> anyhow::Result<()> { From 4b252606b942d37f5e28d543ba5122be2e8fcc6c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:36:22 +0300 Subject: [PATCH 11/30] fix(workflows): reject invalid restart routes Agent: Nausicaa --- codex-rs/ext/workflows/src/activation.rs | 34 ++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index aeffbb1fb..d01f32f18 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -345,6 +345,17 @@ impl WorkflowActivationService { .await { Ok(()) => break, + Err(err) + if err + .downcast_ref::() + .is_some() => + { + tracing::warn!( + workflow_run_id = %run_id, + "workflow activation rejected a non-retriable model route: {err}" + ); + break; + } Err(err) => { tracing::warn!( workflow_run_id = %run_id, @@ -378,6 +389,14 @@ impl WorkflowActivationService { tokio::time::sleep(WORKFLOW_SUPERVISOR_POLL_INTERVAL).await; continue; } + let spec_record = self + .state_db + .workflows() + .get_workflow_spec(snapshot.run.workflow_record_id.as_str()) + .await? + .ok_or_else(|| anyhow::anyhow!("workflow spec record not found"))?; + let spec = parse_workflow_yaml(spec_record.source_yaml.as_str())?; + validate_workflow_routes_before_effects(&spec, &config.route_runtime)?; let claim_params = WorkflowRunClaimParams { run_id: run_id.to_string(), @@ -1451,7 +1470,6 @@ fn validate_workflow_routes_before_effects( runtime: &WorkflowRouteRuntime, ) -> anyhow::Result<()> { for step in &spec.steps { - let path = format!("steps.{}.model", step.id); let route = step.model.as_ref().unwrap_or(&spec.execution_defaults); let workspace_mode = step .workspace @@ -1464,7 +1482,7 @@ fn validate_workflow_routes_before_effects( WorkflowWorkspaceMode::SharedRepository => "shared_repository", }; admit_workflow_model_route_for_runtime(route, runtime, worktree_mode) - .map_err(|err| anyhow::anyhow!("{path}: {err}"))?; + .map_err(anyhow::Error::new)?; } Ok(()) } @@ -1656,6 +1674,12 @@ cleanup: .to_string() .contains("workflow_route_provider_unavailable") ); + assert!( + unavailable_error + .downcast_ref::() + .is_some(), + "route failures must stay typed so restart supervisors do not retry them" + ); let mut mismatched = supported; mismatched.model = Some("gpt-5.3".to_string()); @@ -1666,6 +1690,12 @@ cleanup: .to_string() .contains("workflow_route_model_mismatch") ); + assert!( + mismatch_error + .downcast_ref::() + .is_some(), + "route mismatches must stay typed so restart supervisors do not retry them" + ); } #[test] From 5cae8d4b8209d85a1e6db9aab282c7b95166819b Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:38:04 +0300 Subject: [PATCH 12/30] chore(schema): refresh route enforcement artifacts Agent: Nausicaa --- .../codex_app_server_protocol.schemas.json | 110 +++++++++--------- .../codex_app_server_protocol.v2.schemas.json | 110 +++++++++--------- .../json/v2/WorktreeAttachResponse.json | 110 +++++++++--------- .../json/v2/WorktreeCleanupResponse.json | 110 +++++++++--------- .../json/v2/WorktreeCreateResponse.json | 110 +++++++++--------- .../json/v2/WorktreeDetachResponse.json | 110 +++++++++--------- .../schema/json/v2/WorktreeListResponse.json | 110 +++++++++--------- .../schema/json/v2/WorktreeReadResponse.json | 110 +++++++++--------- .../json/v2/WorktreeReconcileResponse.json | 110 +++++++++--------- .../json/v2/WorktreeReleaseResponse.json | 110 +++++++++--------- codex-rs/core/config.schema.json | 2 +- 11 files changed, 551 insertions(+), 551 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 284592fe5..f2d47dc72 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7221,6 +7221,51 @@ } ] }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentPendingInteraction": { "properties": { "agentId": { @@ -7444,6 +7489,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -7544,16 +7599,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -26767,51 +26812,6 @@ "okOverridden" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } } }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 12a8cc6e7..05e534ad7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -791,6 +791,51 @@ } ] }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentPendingInteraction": { "properties": { "agentId": { @@ -1014,6 +1059,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/definitions/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -1114,16 +1169,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/definitions/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -24828,51 +24873,6 @@ "okOverridden" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "title": "CodexAppServerProtocolV2", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json index 1b37fb8ab..fc5124cec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeAttachResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json index f48bd1da6..1dde466b2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCleanupResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json index 1aa834d51..a75b8434b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json index 97bab8e04..8f0308655 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeDetachResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json index 6c3d95e56..15c35a72b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json index 2a612b80e..29fa11732 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReadResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json index a3c519c24..11af955a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReconcileResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -479,51 +524,6 @@ "auto" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json index 4f410c512..ed9d0579c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeReleaseResponse.json @@ -8,6 +8,51 @@ ], "type": "string" }, + "AgentModelAttestation": { + "properties": { + "agentId": { + "type": "string" + }, + "appliedConfiguration": true, + "appliedModel": { + "type": [ + "string", + "null" + ] + }, + "boundRuntimeModel": { + "type": [ + "string", + "null" + ] + }, + "configFingerprint": { + "type": [ + "string", + "null" + ] + }, + "idempotencyKeySha256": { + "type": [ + "string", + "null" + ] + }, + "requestedConfiguration": true, + "requestedModel": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "agentId", + "requestedConfiguration", + "appliedConfiguration" + ], + "type": "object" + }, "AgentRetentionState": { "enum": [ "active", @@ -133,6 +178,16 @@ "format": "int64", "type": "integer" }, + "modelAttestation": { + "anyOf": [ + { + "$ref": "#/$defs/AgentModelAttestation" + }, + { + "type": "null" + } + ] + }, "parentAgentRunId": { "type": [ "string", @@ -233,16 +288,6 @@ "string", "null" ] - }, - "modelAttestation": { - "anyOf": [ - { - "$ref": "#/$defs/AgentModelAttestation" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -436,51 +481,6 @@ "backgroundAgent" ], "type": "string" - }, - "AgentModelAttestation": { - "properties": { - "agentId": { - "type": "string" - }, - "appliedConfiguration": true, - "appliedModel": { - "type": [ - "string", - "null" - ] - }, - "boundRuntimeModel": { - "type": [ - "string", - "null" - ] - }, - "configFingerprint": { - "type": [ - "string", - "null" - ] - }, - "idempotencyKeySha256": { - "type": [ - "string", - "null" - ] - }, - "requestedConfiguration": true, - "requestedModel": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "agentId", - "requestedConfiguration", - "appliedConfiguration" - ], - "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dac269fd4..edb46e9d8 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5867,4 +5867,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file From 7533344f615001e3be60676eb6db51cc528f546d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:42:07 +0300 Subject: [PATCH 13/30] fix(workflows): preserve descendant worktree route Agent: Nausicaa --- .../background_agent_live.rs | 74 +++++++++++++++++-- .../background_agent_processor.rs | 7 +- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index 3fd7a50a5..763ebfb71 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -1840,11 +1840,11 @@ impl ThreadRequestProcessor { else { return Ok(None); }; - let worktree_mode = if managed_worktree.is_some() { - "isolated" - } else { - "in_place" - }; + let worktree_mode = workflow_descendant_worktree_mode( + receipt.effective.worktree_mode.as_str(), + managed_worktree.is_some(), + ) + .map_err(workflow_route_admission_error)?; let effective = effective_workflow_route_from_config(self.config.as_ref(), &receipt, worktree_mode) .map_err(workflow_route_admission_error)?; @@ -4131,6 +4131,22 @@ fn effective_workflow_route_from_config( }) } +fn workflow_descendant_worktree_mode( + parent_mode: &str, + requests_isolated_worktree: bool, +) -> anyhow::Result<&str> { + match (parent_mode, requests_isolated_worktree) { + ("isolated_worktree", _) => Ok("isolated_worktree"), + ("shared_repository", false) => Ok("shared_repository"), + ("shared_repository", true) => anyhow::bail!( + "workflow_route_worktree_mode_mismatch: descendant requested an isolated worktree outside the immutable parent route" + ), + _ => anyhow::bail!( + "workflow_route_worktree_mode_invalid: immutable parent route has an unsupported worktree mode" + ), + } +} + fn workflow_route_admission_error(err: impl std::fmt::Display) -> JSONRPCErrorError { let message = err.to_string(); let code = message @@ -5804,6 +5820,54 @@ mod tests { use std::sync::atomic::Ordering; use tempfile::TempDir; + #[test] + fn descendant_worktree_mode_keeps_the_canonical_parent_route() { + let shared_parent = workflow_descendant_worktree_mode( + "isolated_worktree", + /*requests_isolated_worktree*/ false, + ) + .expect("a child may share its parent's isolated worktree"); + assert_eq!(shared_parent, "isolated_worktree"); + + let isolated_child = workflow_descendant_worktree_mode( + "isolated_worktree", + /*requests_isolated_worktree*/ true, + ) + .expect("a child may receive another isolated worktree"); + assert_eq!(isolated_child, "isolated_worktree"); + + let shared_child = workflow_descendant_worktree_mode( + "shared_repository", + /*requests_isolated_worktree*/ false, + ) + .expect("a shared-repository child keeps the parent route"); + assert_eq!(shared_child, "shared_repository"); + } + + #[test] + fn descendant_worktree_mode_rejects_downgrade_and_unknown_values() { + let mismatch = + workflow_descendant_worktree_mode( + "shared_repository", + /*requests_isolated_worktree*/ true, + ) + .expect_err("a child must not widen the immutable parent route"); + assert!( + mismatch + .to_string() + .contains("workflow_route_worktree_mode_mismatch") + ); + + let invalid = + workflow_descendant_worktree_mode("isolated", /*requests_isolated_worktree*/ true) + .expect_err("background-agent aliases are not workflow route values"); + assert!( + invalid + .to_string() + .contains("workflow_route_worktree_mode_invalid") + ); + } + #[test] fn spawn_receipt_after_seq_never_goes_negative_for_a_fresh_run() { // Regression for the 100%-at-spawn durable-agent failure: a freshly diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index a6c80ede2..7c7fce5e5 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -1919,7 +1919,10 @@ mod tests { assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); assert_eq!(payload.get("reasoning"), Some(&json!("high"))); - assert_eq!(payload.pointer("/workspace/mode"), Some(&json!("isolated"))); + assert_eq!( + payload.pointer("/workspace/mode"), + Some(&json!("isolated_worktree")) + ); assert_eq!( payload.get("creditAccounting"), Some(&json!({"status": "not_requested"})) @@ -2011,7 +2014,7 @@ mod tests { auth_profile: Some("account007".to_string()), approval_policy: Some("never".to_string()), permission_profile: Some("workspace-write".to_string()), - worktree_mode: "isolated".to_string(), + worktree_mode: "isolated_worktree".to_string(), context_ceiling_tokens: Some(128_000), fallback_used: false, credit_control: WorkflowProviderCreditControl::NotRequested, From 2179a3642738eadf4added06736a196b8dffc9c6 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:44:58 +0300 Subject: [PATCH 14/30] fix(workflows): freeze admitted session routes Agent: Nausicaa --- .../background_agent_live.rs | 11 +++--- codex-rs/core/src/session/session.rs | 38 +++++++++++++++++++ codex-rs/core/src/session/tests.rs | 16 ++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index 763ebfb71..b8b176fd5 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -5846,12 +5846,11 @@ mod tests { #[test] fn descendant_worktree_mode_rejects_downgrade_and_unknown_values() { - let mismatch = - workflow_descendant_worktree_mode( - "shared_repository", - /*requests_isolated_worktree*/ true, - ) - .expect_err("a child must not widen the immutable parent route"); + let mismatch = workflow_descendant_worktree_mode( + "shared_repository", + /*requests_isolated_worktree*/ true, + ) + .expect_err("a child must not widen the immutable parent route"); assert!( mismatch .to_string() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c7d99e051..02e7fb90c 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -222,6 +222,13 @@ impl SessionConfiguration { } pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { + if self + .original_config_do_not_use + .workflow_route_receipt + .is_some() + { + validate_immutable_workflow_route_settings(updates)?; + } let mut next_configuration = self.clone(); let current_sandbox_policy = self.sandbox_policy(); let current_file_system_sandbox_policy = self.file_system_sandbox_policy(); @@ -500,6 +507,37 @@ impl SessionConfiguration { } } +fn validate_immutable_workflow_route_settings( + updates: &SessionSettingsUpdate, +) -> ConstraintResult<()> { + let route_affecting_update = updates.collaboration_mode.is_some() + || updates.worktree_mode.is_some() + || updates.service_tier.is_some() + || updates.model_provider_id.is_some() + || updates.auth_profile.is_some() + || updates.auth_profile_auto_switch_enabled.is_some() + || updates.approval_policy.is_some() + || updates.approvals_reviewer.is_some() + || updates.permission_profile.is_some() + || updates.active_permission_profile.is_some() + || updates.sandbox_policy.is_some() + || updates.windows_sandbox_level.is_some() + || updates.cwd.is_some() + || updates.workspace_roots.is_some() + || updates.profile_workspace_roots.is_some(); + if route_affecting_update { + Err(ConstraintError::InvalidValue { + field_name: "workflow_route", + candidate: "workflow_route_immutable".to_string(), + allowed: "the exact admitted workflow route; route-affecting session updates are disabled" + .to_string(), + requirement_source: codex_config::RequirementSource::Unknown, + }) + } else { + Ok(()) + } +} + fn apply_model_provider_id( next_configuration: &mut SessionConfiguration, model_provider_id: &str, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 712a398a3..b80a6039b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4663,6 +4663,22 @@ async fn session_settings_null_service_tier_update_uses_default_service_tier() { ); } +#[test] +fn immutable_workflow_route_settings_guard_is_two_sided() { + validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + session_prompt: Some(Some("allowed non-route update".to_string())), + ..Default::default() + }) + .expect("non-route session settings remain available"); + + let error = validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + auth_profile: Some(Some("alternate-profile".to_string())), + ..Default::default() + }) + .expect_err("an admitted workflow auth profile must be immutable"); + assert!(error.to_string().contains("workflow_route_immutable")); +} + #[tokio::test] async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { let session_configuration = make_session_configuration_for_tests().await; From 5c680fa217820ee4762cc97a2c2eb09425429556 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:47:13 +0300 Subject: [PATCH 15/30] style(workflows): apply hosted rustfmt output Agent: Nausicaa --- codex-rs/core/src/session/session.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 02e7fb90c..e014f1e1e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -529,8 +529,9 @@ fn validate_immutable_workflow_route_settings( Err(ConstraintError::InvalidValue { field_name: "workflow_route", candidate: "workflow_route_immutable".to_string(), - allowed: "the exact admitted workflow route; route-affecting session updates are disabled" - .to_string(), + allowed: + "the exact admitted workflow route; route-affecting session updates are disabled" + .to_string(), requirement_source: codex_config::RequirementSource::Unknown, }) } else { From ede091432ec8beb3c6681deedc7ff5ea67fd16e8 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:51:24 +0300 Subject: [PATCH 16/30] fix(workflows): satisfy routing clippy gate Agent: Nausicaa --- codex-rs/workflows/src/routing_enforcement.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index b3d500834..9b22f7758 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -549,7 +549,7 @@ fn decimal_is_zero(value: &str) -> bool { && value.chars().any(|character| character.is_ascii_digit()) && value .chars() - .filter(|character| character.is_ascii_digit()) + .filter(char::is_ascii_digit) .all(|character| character == '0') } From c117bb4824d0a7f5b60c72841704ea0c06f7fe05 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sat, 8 Aug 2026 23:57:15 +0300 Subject: [PATCH 17/30] fix(state): remove stale route packet field Agent: Nausicaa --- codex-rs/state/src/runtime/workflow_orchestrator.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 61aa8cccc..457777b56 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -835,7 +835,6 @@ WHERE step_run_id = ? run, candidate: &candidate, attempt: branch_attempt, - model_route_json: &model_route_json, route_receipt: &route_receipt, workspace_json: workspace_json.as_ref(), provisioned_workspace: &provisioned_workspace, @@ -1120,7 +1119,6 @@ struct BackgroundBranchRunCreate<'a> { run: &'a crate::WorkflowRun, candidate: &'a ReadyBranchCandidate, attempt: i64, - model_route_json: &'a Value, route_receipt: &'a WorkflowRouteReceipt, workspace_json: Option<&'a Value>, provisioned_workspace: &'a ProvisionedWorkflowWorkspace, @@ -1386,7 +1384,6 @@ async fn create_background_branch_run_if_missing_in_tx( run, candidate, attempt, - model_route_json: _, route_receipt, workspace_json, provisioned_workspace, From 951e821793d5786977c06c3a30dd922e639c7e3c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 00:50:21 +0300 Subject: [PATCH 18/30] fix(workflows): close hosted routing gaps Agent: Nausicaa --- codex-rs/app-server/README.md | 2 +- .../background_agent_processor.rs | 21 ++- codex-rs/core/src/client.rs | 17 +- codex-rs/core/src/client_tests.rs | 12 +- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/ext/workflows/src/activation.rs | 5 + codex-rs/ext/workflows/src/manager_tool.rs | 4 +- .../src/runtime/workflow_orchestrator.rs | 50 +++--- codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/workflows/src/routing_enforcement.rs | 167 ++++++++++++++---- 11 files changed, 209 insertions(+), 76 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 45259c2c6..8626382fa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1154,7 +1154,7 @@ Use `thread/workflow/get` to read one saved workflow metadata record, or `thread Use `thread/workflow/run/start` to start a saved workflow for the same thread. The response includes sanitized run state and the projected goal plan when projection succeeds. Use run list/get for inspection and pause/resume/cancel for lifecycle control. -Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. An `agent/start` request whose `parentAgentRunId` identifies a workflow worker inherits that exact receipt, must use an isolated worktree when the receipt requires one, and is rejected before admission if the configured descendant route differs. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work rather than applying a caller estimate after launch. +Workflow execution treats the saved model route as an immutable admission contract. Before the run, goal projection, worktree, background worker, verifier, or provider process can start, the server checks the configured gateway, provider, model, reasoning effort, optional service tier and auth/approval/permission profiles, worktree mode, context ceiling, and fallback decision against every route constraint. The requested and effective route receipt is persisted with branch execution state and is checked again for retries, recovered workers, descendants, and verifier attempts; a mismatch returns a stable `workflow_route_*` error instead of selecting another route. An `agent/start` request whose `parentAgentRunId` identifies a workflow worker inherits that exact receipt, must use an isolated worktree when the receipt requires one, and is rejected before admission if the configured descendant route differs. Finite `budget_usd` routes require a provider-backed pre-launch credit reservation plus terminal usage readback. The current background-agent provider path has no such controller, so it rejects finite budgets with `workflow_route_credit_ceiling_unavailable` before billed work. Serialized or caller-supplied reservation and usage fields cannot authorize a launch and do not substitute for that controller. ```json { "method": "thread/workflow/run/start", "id": 36, "params": { diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 7c7fce5e5..365c2b087 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -227,7 +227,7 @@ impl BackgroundAgentRequestProcessor { config_fingerprint: config_fingerprint.as_deref(), version_fingerprint: version_fingerprint.as_deref(), inherited_workflow_route, - }); + })?; let requested_model = execution_context .as_ref() .and_then(|context| context.model.clone()); @@ -1158,7 +1158,7 @@ struct InitialExecutionSnapshotPayloadParams<'a> { fn initial_execution_snapshot_payload( params: InitialExecutionSnapshotPayloadParams<'_>, -) -> serde_json::Value { +) -> Result { let mut payload = json!({ "snapshotSource": "agent/start", "cwd": params.cwd, @@ -1217,9 +1217,9 @@ fn initial_execution_snapshot_payload( "midTurnCrashSemantics": "abort_mid_turn_resume_at_safe_boundary", }); if let Some(receipt) = params.inherited_workflow_route { - let payload = payload - .as_object_mut() - .expect("initial execution snapshot payload is an object"); + let payload = payload.as_object_mut().ok_or_else(|| { + internal_error("initial execution snapshot payload must be an object".to_string()) + })?; payload.insert( "modelGateway".to_string(), json!(receipt.effective.model_gateway), @@ -1253,7 +1253,7 @@ fn initial_execution_snapshot_payload( json!({"mode": receipt.effective.worktree_mode}), ); } - payload + Ok(payload) } fn background_agent_config_fingerprint( @@ -1914,7 +1914,8 @@ mod tests { config_fingerprint: Some("cfg-1"), version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), inherited_workflow_route: Some(&receipt), - }); + }) + .expect("workflow descendant payload should serialize"); assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); @@ -1941,7 +1942,8 @@ mod tests { config_fingerprint: Some("cfg-1"), version_fingerprint: Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION), inherited_workflow_route: None, - }); + }) + .expect("ordinary agent payload should serialize"); assert_eq!(payload.get("routeReceipt"), None); } @@ -1979,7 +1981,8 @@ mod tests { Some("worktree-1"), Some(&receipt), ) - .await?; + .await + .expect("workflow descendant admission should succeed"); assert_eq!(response.agent.source, "workflow-descendant"); assert_eq!( diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e2ba71b75..b1ad86e88 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -474,6 +474,11 @@ impl ModelClient { let mut effective = receipt.effective.clone(); effective.provider = self.state.provider_id.clone(); effective.model = model_info.slug.clone(); + effective.auth_profile = self + .state + .provider + .auth_manager() + .and_then(|manager| manager.selected_auth_profile()); effective.reasoning = effort.map(ToString::to_string).ok_or_else(|| { CodexErr::InvalidRequest( "workflow_route_reasoning_unavailable: provider request has no reasoning effort" @@ -739,7 +744,11 @@ impl ModelClient { return Ok(Vec::new()); } - self.enforce_workflow_provider_attempt(model_info, effort.as_ref(), None)?; + self.enforce_workflow_provider_attempt( + model_info, + effort.as_ref(), + /*service_tier*/ None, + )?; let client_setup = self.current_client_setup().await?; let transport = ReqwestTransport::new(build_reqwest_client()); @@ -1349,6 +1358,12 @@ impl ModelClientSession { if !self.responses_websocket_enabled() { return Ok(()); } + // A preconnect has neither reasoning nor service-tier inputs, so it cannot prove the + // immutable workflow route. Defer the connection until the first real request, where the + // complete route is enforced immediately before the provider attempt. + if self.client.workflow_route_receipt.is_some() { + return Ok(()); + } if self.websocket_session.connection.is_some() { return Ok(()); } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index fc0292fda..cb90866f1 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -192,7 +192,11 @@ fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { let provider_calls = AtomicUsize::new(0); client - .enforce_workflow_provider_attempt(&test_model_info(), Some(&ReasoningEffort::Medium), None) + .enforce_workflow_provider_attempt( + &test_model_info(), + Some(&ReasoningEffort::Medium), + /*service_tier*/ None, + ) .expect("the exact admitted request must pass"); provider_calls.fetch_add(1, Ordering::SeqCst); assert_eq!(provider_calls.load(Ordering::SeqCst), 1); @@ -200,7 +204,11 @@ fn workflow_provider_call_guard_is_two_sided_and_fails_before_call() { let mut mismatched_model = test_model_info(); mismatched_model.slug = "gpt-other".to_string(); let error = client - .enforce_workflow_provider_attempt(&mismatched_model, Some(&ReasoningEffort::Medium), None) + .enforce_workflow_provider_attempt( + &mismatched_model, + Some(&ReasoningEffort::Medium), + /*service_tier*/ None, + ) .expect_err("a mismatched provider request must fail before the call"); assert!(error.to_string().contains("workflow_route_model_mismatch")); assert_eq!(provider_calls.load(Ordering::SeqCst), 1); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e014f1e1e..e77138556 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -507,7 +507,7 @@ impl SessionConfiguration { } } -fn validate_immutable_workflow_route_settings( +pub(super) fn validate_immutable_workflow_route_settings( updates: &SessionSettingsUpdate, ) -> ConstraintResult<()> { let route_affecting_update = updates.collaboration_mode.is_some() diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b80a6039b..692ceb1ce 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4665,13 +4665,13 @@ async fn session_settings_null_service_tier_update_uses_default_service_tier() { #[test] fn immutable_workflow_route_settings_guard_is_two_sided() { - validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { session_prompt: Some(Some("allowed non-route update".to_string())), ..Default::default() }) .expect("non-route session settings remain available"); - let error = validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + let error = super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { auth_profile: Some(Some("alternate-profile".to_string())), ..Default::default() }) diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index d01f32f18..7dc1f9611 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -1621,6 +1621,11 @@ steps: - id: "run" title: "Run exact route" agent: "worker" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" depends_on: [] outputs: - "result.md" diff --git a/codex-rs/ext/workflows/src/manager_tool.rs b/codex-rs/ext/workflows/src/manager_tool.rs index 41d81ab04..bb675de0e 100644 --- a/codex-rs/ext/workflows/src/manager_tool.rs +++ b/codex-rs/ext/workflows/src/manager_tool.rs @@ -39,7 +39,7 @@ enum ManageWorkflowRuntime { state_db: Arc, thread_id: ThreadId, activation_service: Arc, - activation_config: WorkflowActivationConfig, + activation_config: Box, }, Unavailable { reason: &'static str, @@ -76,7 +76,7 @@ impl ManageWorkflowTool { state_db, thread_id, activation_service, - activation_config, + activation_config: Box::new(activation_config), }, } } diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 457777b56..5aeb7b6e4 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -3300,6 +3300,15 @@ cleanup: reasoning: "xhigh" service_tier: "priority" permission_profile: "read-only" +"# + .to_string() + } else if index == 0 && title_suffix == "typed-permission-profile" { + r#" model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + permission_profile: "read-only" "# .to_string() } else { @@ -4042,14 +4051,14 @@ WHERE plan_id = ? AND key = ? assert_eq!(1, ready_steps); let first_branch = &admitted.admitted[0]; assert_eq!( - Some("openrouter"), + Some("hasna"), first_branch .model_route_json .get("model_gateway") .and_then(Value::as_str) ); assert_eq!( - Some("xhigh"), + Some("high"), first_branch .model_route_json .get("reasoning") @@ -4077,14 +4086,14 @@ WHERE plan_id = ? AND key = ? .expect("execution snapshot should load") .expect("execution snapshot should exist"); assert_eq!( - Some("openrouter"), + Some("hasna"), execution_snapshot .payload_json .get("modelGateway") .and_then(Value::as_str) ); assert_eq!( - Some("xhigh"), + Some("high"), execution_snapshot .payload_json .get("reasoning") @@ -4502,10 +4511,10 @@ WHERE worktree_id = ? "wf_branch_openrouter_missing_env", parallel_branch_workflow_yaml( "wf_branch_openrouter_missing_env", - /*step_count*/ 2, - /*max_parallel_steps*/ 2, - /*max_agents*/ 2, - /*max_worktrees*/ 2, + /*step_count*/ 1, + /*max_parallel_steps*/ 1, + /*max_agents*/ 1, + /*max_worktrees*/ 1, "missing-openrouter-env", ), ) @@ -4582,20 +4591,12 @@ WHERE worktree_id = ? }) .collect::>(); assert_eq!( - vec![ - ( - "branch_0".to_string(), - crate::WorkflowRunStepStatus::Blocked, - Some(WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string()), - None, - ), - ( - "branch_1".to_string(), - crate::WorkflowRunStepStatus::Ready, - None, - None, - ), - ], + vec![( + "branch_0".to_string(), + crate::WorkflowRunStepStatus::Blocked, + Some(WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string()), + None, + )], step_states ); let branch_verifier = admitted @@ -4666,10 +4667,7 @@ WHERE worktree_id = ? assert_eq!(projection.plan_id, plan.plan.plan_id); assert_eq!(crate::ThreadGoalPlanStatus::Blocked, plan.plan.status); assert_eq!( - vec![ - crate::ThreadGoalPlanNodeStatus::Blocked, - crate::ThreadGoalPlanNodeStatus::Blocked, - ], + vec![crate::ThreadGoalPlanNodeStatus::Blocked], plan.nodes .iter() .map(|node| node.status) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index f79df74f4..0d61a69f1 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -178,6 +178,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_provider_id, model_gateway_id: HASNA_GATEWAY_ID.to_string(), model_provider, + workflow_route_receipt: None, personality: None, permissions: Permissions::from_approval_and_profile( Constrained::allow_any(AskForApproval::Never), diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index 9b22f7758..5bb50f08d 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -220,6 +220,14 @@ pub fn admit_workflow_model_route( .fallback .as_ref() .is_some_and(|fallback| fallback.used); + if (decision.status == WorkflowModelRoutingDecisionStatus::Fallback) + != decision_fallback_used + { + return Err(route_error( + "workflow_route_fallback_mismatch", + "routing fallback status and usage marker disagree", + )); + } if decision_fallback_used != effective.fallback_used { return Err(route_error( "workflow_route_fallback_mismatch", @@ -526,7 +534,10 @@ fn enforce_credit_control( "provider reservation has no credit remaining", )); } - Ok(()) + Err(route_error( + "workflow_route_credit_ceiling_unavailable", + "serialized provider accounting cannot establish the required pre-launch credit controller", + )) } (Some(_), WorkflowProviderCreditControl::NotRequested) => Err(route_error( "workflow_route_credit_ceiling_unavailable", @@ -815,6 +826,18 @@ mod tests { Ok(()) } + fn invoke_provider_after_runtime_admission( + requested: &WorkflowModelRoute, + runtime: &crate::WorkflowRouteRuntime, + worktree_mode: &str, + provider_calls: &AtomicUsize, + ) -> Result<(), crate::WorkflowRouteEnforcementError> { + let receipt = admit_workflow_model_route_for_runtime(requested, runtime, worktree_mode)?; + receipt.enforce_provider_attempt(&receipt.effective)?; + provider_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + #[test] fn fully_supported_exact_route_persists_immutable_requested_and_effective_receipt() { let route = exact_route(); @@ -834,6 +857,15 @@ mod tests { invoke_provider_after_route_admission(&route, &effective, &provider_calls) .expect("fully supported route can invoke the provider"); assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + let runtime_provider_calls = AtomicUsize::new(0); + invoke_provider_after_runtime_admission( + &route, + &runtime(), + "isolated_worktree", + &runtime_provider_calls, + ) + .expect("fully supported runtime route can invoke the provider"); + assert_eq!(runtime_provider_calls.load(Ordering::SeqCst), 1); } #[test] @@ -849,46 +881,47 @@ mod tests { #[test] fn every_disallowed_or_mismatched_route_dimension_fails_closed() { - let cases: Vec<(&str, Box)> = vec![ + type EffectiveRouteMutation = fn(&mut WorkflowEffectiveModelRoute); + let cases: [(&str, EffectiveRouteMutation); 10] = [ ( "workflow_route_gateway_mismatch", - Box::new(|route| route.model_gateway = "direct".to_string()), + |route| route.model_gateway = "direct".to_string(), ), ( "workflow_route_provider_mismatch", - Box::new(|route| route.provider = "openai".to_string()), + |route| route.provider = "openai".to_string(), ), ( "workflow_route_model_mismatch", - Box::new(|route| route.model = "openai/gpt-4.1".to_string()), + |route| route.model = "openai/gpt-4.1".to_string(), ), ( "workflow_route_reasoning_mismatch", - Box::new(|route| route.reasoning = "medium".to_string()), + |route| route.reasoning = "medium".to_string(), ), ( "workflow_route_service_tier_mismatch", - Box::new(|route| route.service_tier = Some("default".to_string())), + |route| route.service_tier = Some("default".to_string()), ), ( "workflow_route_auth_profile_mismatch", - Box::new(|route| route.auth_profile = Some("account008".to_string())), + |route| route.auth_profile = Some("account008".to_string()), ), ( "workflow_route_approval_profile_mismatch", - Box::new(|route| route.approval_policy = Some("on-request".to_string())), + |route| route.approval_policy = Some("on-request".to_string()), ), ( "workflow_route_permission_profile_mismatch", - Box::new(|route| route.permission_profile = Some(":read-only".to_string())), + |route| route.permission_profile = Some(":read-only".to_string()), ), ( "workflow_route_worktree_mode_mismatch", - Box::new(|route| route.worktree_mode = "shared_repository".to_string()), + |route| route.worktree_mode = "shared_repository".to_string(), ), ( "workflow_route_context_ceiling_exceeded", - Box::new(|route| route.context_ceiling_tokens = Some(128_001)), + |route| route.context_ceiling_tokens = Some(128_001), ), ]; @@ -906,40 +939,71 @@ mod tests { #[test] fn unavailable_required_runtime_dimensions_fail_before_provider_attempt() { - let cases: Vec<(&str, Box)> = vec![ + type RuntimeRouteMutation = fn(&mut crate::WorkflowRouteRuntime); + let cases: [(&str, RuntimeRouteMutation); 9] = [ ( "workflow_route_gateway_unavailable", - Box::new(|runtime| runtime.model_gateway = None), + |runtime| runtime.model_gateway = None, ), ( "workflow_route_provider_unavailable", - Box::new(|runtime| runtime.provider = None), + |runtime| runtime.provider = None, ), ( "workflow_route_model_unavailable", - Box::new(|runtime| runtime.model = None), + |runtime| runtime.model = None, ), ( "workflow_route_reasoning_unavailable", - Box::new(|runtime| runtime.reasoning = None), + |runtime| runtime.reasoning = None, ), ( "workflow_route_context_ceiling_unavailable", - Box::new(|runtime| runtime.context_window_tokens = None), + |runtime| runtime.context_window_tokens = None, + ), + ( + "workflow_route_service_tier_mismatch", + |runtime| runtime.service_tier = None, + ), + ( + "workflow_route_auth_profile_mismatch", + |runtime| runtime.auth_profile = None, + ), + ( + "workflow_route_approval_profile_mismatch", + |runtime| runtime.approval_policy = None, + ), + ( + "workflow_route_permission_profile_mismatch", + |runtime| runtime.permission_profile = None, ), ]; for (expected_code, mutate) in cases { let mut runtime = runtime(); mutate(&mut runtime); - let error = admit_workflow_model_route_for_runtime( + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_runtime_admission( &exact_route(), &runtime, "isolated_worktree", + &provider_calls, ) .expect_err("unavailable runtime dimension must fail closed"); assert_eq!(error.code(), expected_code); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } + + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_runtime_admission( + &exact_route(), + &runtime(), + "", + &provider_calls, + ) + .expect_err("missing worktree mode must fail closed"); + assert_eq!(error.code(), "workflow_route_worktree_mode_mismatch"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } #[test] @@ -961,6 +1025,51 @@ mod tests { assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } + #[test] + fn selected_route_cannot_claim_silent_fallback_use() { + let mut route = exact_route(); + let decision = route + .routing + .as_mut() + .expect("routing contract") + .decision + .as_mut() + .expect("routing decision"); + decision.fallback = Some(crate::WorkflowModelRoutingFallback { + used: true, + reason: Some("unadmitted alternate route".to_string()), + }); + let provider_calls = AtomicUsize::new(0); + + let error = + invoke_provider_after_route_admission(&route, &effective_route(), &provider_calls) + .expect_err("selected routes cannot silently claim fallback use"); + + assert_eq!(error.code(), "workflow_route_fallback_mismatch"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn explicit_required_fallback_route_can_invoke_provider() { + let mut route = exact_route(); + let routing = route.routing.as_mut().expect("routing contract"); + routing.request.constraints.fallback_required = true; + let decision = routing.decision.as_mut().expect("routing decision"); + decision.status = WorkflowModelRoutingDecisionStatus::Fallback; + decision.fallback = Some(crate::WorkflowModelRoutingFallback { + used: true, + reason: Some("preferred route unavailable".to_string()), + }); + let mut effective = effective_route(); + effective.fallback_used = true; + let provider_calls = AtomicUsize::new(0); + + invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect("explicit required fallback is a supported immutable route"); + + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + } + #[test] fn finite_budget_without_provider_reservation_fails_before_provider_attempt() { let mut route = exact_route(); @@ -1008,7 +1117,7 @@ mod tests { } #[test] - fn reserved_credit_ceiling_is_immutable_and_terminal_accounting_is_exact() { + fn caller_supplied_credit_readback_cannot_authorize_billed_work() { let mut route = exact_route(); route .routing @@ -1026,18 +1135,12 @@ mod tests { exhausted: false, }; - let receipt = admit_workflow_model_route(&route, &effective).expect("reserved route"); - assert_eq!(receipt.enforce_provider_attempt(&effective), Ok(())); - assert_eq!( - receipt.terminal_credit_accounting(), - crate::WorkflowProviderCreditTerminalAccounting::ProviderReadback { - reservation_id: "reservation-1".to_string(), - ceiling_usd: "5.00".to_string(), - spent_usd: "1.25".to_string(), - remaining_usd: "3.75".to_string(), - exhausted: false, - } - ); + let provider_calls = AtomicUsize::new(0); + let error = invoke_provider_after_route_admission(&route, &effective, &provider_calls) + .expect_err("caller-supplied accounting cannot stand in for a provider controller"); + + assert_eq!(error.code(), "workflow_route_credit_ceiling_unavailable"); + assert_eq!(provider_calls.load(Ordering::SeqCst), 0); } #[test] From 54da03e5d1e014d122ae68891cf59b840ffd20ad Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 00:54:20 +0300 Subject: [PATCH 19/30] style(workflows): apply hosted rustfmt Agent: Nausicaa --- codex-rs/core/src/session/tests.rs | 11 +- codex-rs/workflows/src/routing_enforcement.rs | 133 ++++++++---------- 2 files changed, 63 insertions(+), 81 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 692ceb1ce..ff713f5a6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4671,11 +4671,12 @@ fn immutable_workflow_route_settings_guard_is_two_sided() { }) .expect("non-route session settings remain available"); - let error = super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { - auth_profile: Some(Some("alternate-profile".to_string())), - ..Default::default() - }) - .expect_err("an admitted workflow auth profile must be immutable"); + let error = + super::session::validate_immutable_workflow_route_settings(&SessionSettingsUpdate { + auth_profile: Some(Some("alternate-profile".to_string())), + ..Default::default() + }) + .expect_err("an admitted workflow auth profile must be immutable"); assert!(error.to_string().contains("workflow_route_immutable")); } diff --git a/codex-rs/workflows/src/routing_enforcement.rs b/codex-rs/workflows/src/routing_enforcement.rs index 5bb50f08d..d00f18fc7 100644 --- a/codex-rs/workflows/src/routing_enforcement.rs +++ b/codex-rs/workflows/src/routing_enforcement.rs @@ -883,46 +883,36 @@ mod tests { fn every_disallowed_or_mismatched_route_dimension_fails_closed() { type EffectiveRouteMutation = fn(&mut WorkflowEffectiveModelRoute); let cases: [(&str, EffectiveRouteMutation); 10] = [ - ( - "workflow_route_gateway_mismatch", - |route| route.model_gateway = "direct".to_string(), - ), - ( - "workflow_route_provider_mismatch", - |route| route.provider = "openai".to_string(), - ), - ( - "workflow_route_model_mismatch", - |route| route.model = "openai/gpt-4.1".to_string(), - ), - ( - "workflow_route_reasoning_mismatch", - |route| route.reasoning = "medium".to_string(), - ), - ( - "workflow_route_service_tier_mismatch", - |route| route.service_tier = Some("default".to_string()), - ), - ( - "workflow_route_auth_profile_mismatch", - |route| route.auth_profile = Some("account008".to_string()), - ), - ( - "workflow_route_approval_profile_mismatch", - |route| route.approval_policy = Some("on-request".to_string()), - ), - ( - "workflow_route_permission_profile_mismatch", - |route| route.permission_profile = Some(":read-only".to_string()), - ), - ( - "workflow_route_worktree_mode_mismatch", - |route| route.worktree_mode = "shared_repository".to_string(), - ), - ( - "workflow_route_context_ceiling_exceeded", - |route| route.context_ceiling_tokens = Some(128_001), - ), + ("workflow_route_gateway_mismatch", |route| { + route.model_gateway = "direct".to_string() + }), + ("workflow_route_provider_mismatch", |route| { + route.provider = "openai".to_string() + }), + ("workflow_route_model_mismatch", |route| { + route.model = "openai/gpt-4.1".to_string() + }), + ("workflow_route_reasoning_mismatch", |route| { + route.reasoning = "medium".to_string() + }), + ("workflow_route_service_tier_mismatch", |route| { + route.service_tier = Some("default".to_string()) + }), + ("workflow_route_auth_profile_mismatch", |route| { + route.auth_profile = Some("account008".to_string()) + }), + ("workflow_route_approval_profile_mismatch", |route| { + route.approval_policy = Some("on-request".to_string()) + }), + ("workflow_route_permission_profile_mismatch", |route| { + route.permission_profile = Some(":read-only".to_string()) + }), + ("workflow_route_worktree_mode_mismatch", |route| { + route.worktree_mode = "shared_repository".to_string() + }), + ("workflow_route_context_ceiling_exceeded", |route| { + route.context_ceiling_tokens = Some(128_001) + }), ]; for (expected_code, mutate) in cases { @@ -941,42 +931,33 @@ mod tests { fn unavailable_required_runtime_dimensions_fail_before_provider_attempt() { type RuntimeRouteMutation = fn(&mut crate::WorkflowRouteRuntime); let cases: [(&str, RuntimeRouteMutation); 9] = [ - ( - "workflow_route_gateway_unavailable", - |runtime| runtime.model_gateway = None, - ), - ( - "workflow_route_provider_unavailable", - |runtime| runtime.provider = None, - ), - ( - "workflow_route_model_unavailable", - |runtime| runtime.model = None, - ), - ( - "workflow_route_reasoning_unavailable", - |runtime| runtime.reasoning = None, - ), - ( - "workflow_route_context_ceiling_unavailable", - |runtime| runtime.context_window_tokens = None, - ), - ( - "workflow_route_service_tier_mismatch", - |runtime| runtime.service_tier = None, - ), - ( - "workflow_route_auth_profile_mismatch", - |runtime| runtime.auth_profile = None, - ), - ( - "workflow_route_approval_profile_mismatch", - |runtime| runtime.approval_policy = None, - ), - ( - "workflow_route_permission_profile_mismatch", - |runtime| runtime.permission_profile = None, - ), + ("workflow_route_gateway_unavailable", |runtime| { + runtime.model_gateway = None + }), + ("workflow_route_provider_unavailable", |runtime| { + runtime.provider = None + }), + ("workflow_route_model_unavailable", |runtime| { + runtime.model = None + }), + ("workflow_route_reasoning_unavailable", |runtime| { + runtime.reasoning = None + }), + ("workflow_route_context_ceiling_unavailable", |runtime| { + runtime.context_window_tokens = None + }), + ("workflow_route_service_tier_mismatch", |runtime| { + runtime.service_tier = None + }), + ("workflow_route_auth_profile_mismatch", |runtime| { + runtime.auth_profile = None + }), + ("workflow_route_approval_profile_mismatch", |runtime| { + runtime.approval_policy = None + }), + ("workflow_route_permission_profile_mismatch", |runtime| { + runtime.permission_profile = None + }), ]; for (expected_code, mutate) in cases { From e41a037e549ab09541484ecde015043977084889 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 01:05:22 +0300 Subject: [PATCH 20/30] fix(app-server): persist descendant requested route Agent: Nausicaa --- .../background_agent_processor.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 365c2b087..58883e881 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -257,7 +257,7 @@ impl BackgroundAgentRequestProcessor { BackgroundAgentModelAttestationCreateParams { requested_model: Some(receipt.requested.model.clone()), requested_configuration: json!({ - "executionContext": execution_context.as_ref(), + "executionContext": &receipt.requested, "routeReceipt": receipt, }), applied_model: Some(receipt.effective.model.clone()), @@ -1989,6 +1989,17 @@ mod tests { response.agent.parent_agent_run_id.as_deref(), Some("workflow-parent-1") ); + let attestation = response + .agent + .model_attestation + .as_ref() + .expect("workflow descendant should persist model attestation"); + assert_eq!( + attestation + .requested_configuration + .pointer("/executionContext/model"), + Some(&json!("openai/gpt-5.6-sol")) + ); assert_eq!( response.execution_snapshot.payload.get("routeReceipt"), Some(&json!(receipt)) From 726885dae9fc7e52db7ee4ea73f314eadfd93bea Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 01:26:55 +0300 Subject: [PATCH 21/30] test(workflows): align runtime route fixtures Agent: Nausicaa --- .../app-server/tests/suite/v2/workflow.rs | 29 ++++--- codex-rs/ext/workflows/src/activation.rs | 2 +- codex-rs/ext/workflows/src/manager_tool.rs | 87 ++++++++++++++++++- 3 files changed, 100 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 24176498f..b111a338c 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -879,6 +879,7 @@ fn create_config_toml_with_sandbox_mode( format!( r#" model = "mock-model" +model_reasoning_effort = "high" approval_policy = "never" sandbox_mode = "{sandbox_mode}" @@ -1096,8 +1097,8 @@ source_prompt: "Build a serious workflow without leaking {RAW_SENTINEL}" status: "draft" execution_defaults: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" limits: max_parallel_steps: 2 @@ -1115,24 +1116,24 @@ agents: role: "Own the architecture and implementation map." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" - id: "adversarial_security" display_name: "Adversary-Hypatia" role: "Adversarially attack the security and leakage assumptions." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" - id: "adversarial_testing" display_name: "Adversary-Euclid" role: "Adversarially attack the deterministic test evidence." model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" steps: - id: "design" @@ -1140,8 +1141,8 @@ steps: agent: "architect" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: [] outputs: @@ -1161,8 +1162,8 @@ steps: parallel_group: "adversarial_review" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: - "design" @@ -1183,8 +1184,8 @@ steps: parallel_group: "adversarial_review" model: model_gateway: "hasna" - provider: "openai" - model: "gpt-5.4" + provider: "mock_provider" + model: "mock-model" reasoning: "high" depends_on: - "design" diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index 7dc1f9611..1b934ac6d 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -1610,7 +1610,7 @@ approvals: required_before: [] agents: - id: "worker" - display_name: "Worker" + display_name: "Builder-Vitruvius" role: "Prove route activation." model: model_gateway: "hasna" diff --git a/codex-rs/ext/workflows/src/manager_tool.rs b/codex-rs/ext/workflows/src/manager_tool.rs index bb675de0e..9ce3f537e 100644 --- a/codex-rs/ext/workflows/src/manager_tool.rs +++ b/codex-rs/ext/workflows/src/manager_tool.rs @@ -54,12 +54,27 @@ impl ManageWorkflowTool { thread_id: ThreadId, ) -> Self { let activation_service = Arc::new(WorkflowActivationService::new(Arc::clone(&state_db))); + let activation_config = WorkflowActivationConfig { + route_runtime: codex_workflows::WorkflowRouteRuntime { + model_gateway: Some("hasna".to_string()), + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + reasoning: Some("high".to_string()), + service_tier: None, + auth_profile: None, + approval_policy: Some("never".to_string()), + permission_profile: Some("read-only".to_string()), + context_window_tokens: Some(256_000), + credit_control: codex_workflows::WorkflowProviderCreditControl::Unavailable, + }, + ..WorkflowActivationConfig::default() + }; Self::new_with_activation( enabled, state_db, thread_id, activation_service, - WorkflowActivationConfig::default(), + activation_config, ) } @@ -626,7 +641,6 @@ mod tests { use codex_extension_api::ToolExecutor; use codex_extension_api::ToolPayload; use codex_extension_api::ToolSpec; - use codex_prompts::DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_tools::ToolExposure; @@ -638,6 +652,73 @@ mod tests { use super::MANAGE_WORKFLOW_TOOL_NAME; use super::ManageWorkflowTool; + const MANAGE_WORKFLOW_TEST_YAML: &str = r#" +schema_version: "workflow.codex.codewith/v0" +workflow_id: "wf_manage_workflow_test" +display_name: "Manage Workflow Test" +source_prompt: "Exercise workflow lifecycle operations without external effects." +status: "draft" +execution_defaults: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" +limits: + max_parallel_steps: 1 + max_agents: 1 + max_worktrees: 1 + max_runtime_seconds: 300 + max_step_runtime_seconds: 120 + max_tokens: 1000 + max_tool_calls: 10 +approvals: + required_before: + - "production_deploy" +agents: + - id: "builder" + display_name: "Builder-Vitruvius" + role: "Exercise the workflow lifecycle gate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" +steps: + - id: "launch_gate" + title: "Hold at the explicit launch gate" + agent: "builder" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" + approval_gate: "production_deploy" + depends_on: [] + outputs: + - "result.md" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "result_present" + type: "artifact_contains" + artifact: "result.md" + must_contain: + - "done" +artifacts: + retention: "until_workflow_complete" + required: + - "result.md" +cleanup: + on_cancel: [] + on_complete: [] +"#; + #[tokio::test] async fn manage_workflow_lifecycle_returns_sanitized_state() { let tempdir = tempfile::tempdir().expect("tempdir"); @@ -825,7 +906,7 @@ mod tests { &tool, json!({ "action": "create", - "yaml": DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML, + "yaml": MANAGE_WORKFLOW_TEST_YAML, }), ) .await; From 838f74a343f1cccd0f511e2fce46ddf843a6ff0c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 01:36:40 +0300 Subject: [PATCH 22/30] test(workflows): satisfy route fixture invariants Agent: Nausicaa --- codex-rs/app-server/tests/suite/v2/workflow.rs | 1 + codex-rs/ext/workflows/src/activation.rs | 8 ++++---- codex-rs/ext/workflows/src/manager_tool.rs | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index b111a338c..5f4d1d2d6 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -882,6 +882,7 @@ model = "mock-model" model_reasoning_effort = "high" approval_policy = "never" sandbox_mode = "{sandbox_mode}" +permission_profile = "read-only" model_provider = "mock_provider" suppress_unstable_features_warning = true diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index 1b934ac6d..f2798f6e6 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -1609,9 +1609,9 @@ limits: approvals: required_before: [] agents: - - id: "worker" - display_name: "Builder-Vitruvius" - role: "Prove route activation." + - id: "adversarial_worker" + display_name: "Adversary-Hypatia" + role: "Adversarially prove route activation." model: model_gateway: "hasna" provider: "openai" @@ -1620,7 +1620,7 @@ agents: steps: - id: "run" title: "Run exact route" - agent: "worker" + agent: "adversarial_worker" model: model_gateway: "hasna" provider: "openai" diff --git a/codex-rs/ext/workflows/src/manager_tool.rs b/codex-rs/ext/workflows/src/manager_tool.rs index 9ce3f537e..18f7f8314 100644 --- a/codex-rs/ext/workflows/src/manager_tool.rs +++ b/codex-rs/ext/workflows/src/manager_tool.rs @@ -677,9 +677,9 @@ approvals: required_before: - "production_deploy" agents: - - id: "builder" - display_name: "Builder-Vitruvius" - role: "Exercise the workflow lifecycle gate." + - id: "adversarial_reviewer" + display_name: "Adversary-Hypatia" + role: "Adversarially exercise the workflow lifecycle gate." model: model_gateway: "hasna" provider: "openai" @@ -690,7 +690,7 @@ agents: steps: - id: "launch_gate" title: "Hold at the explicit launch gate" - agent: "builder" + agent: "adversarial_reviewer" model: model_gateway: "hasna" provider: "openai" From 81d26c6d831d5b028d4558b35a8082f0f298c92f Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 01:46:47 +0300 Subject: [PATCH 23/30] test(app-server): use canonical permission profile Agent: Nausicaa --- codex-rs/app-server/tests/suite/v2/workflow.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 5f4d1d2d6..ffb954a41 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -882,7 +882,7 @@ model = "mock-model" model_reasoning_effort = "high" approval_policy = "never" sandbox_mode = "{sandbox_mode}" -permission_profile = "read-only" +permission_profile = ":read-only" model_provider = "mock_provider" suppress_unstable_features_warning = true @@ -987,7 +987,7 @@ execution_defaults: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":read-only" limits: max_parallel_steps: 1 max_agents: 2 @@ -1008,7 +1008,7 @@ agents: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":read-only" - id: "adversarial_reviewer" display_name: "Reviewer-Hypatia" role: "Independently review the exact activation candidate." @@ -1029,7 +1029,7 @@ steps: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "read-only" + permission_profile: ":read-only" workspace: mode: "isolated_worktree" depends_on: [] From 33880da2d26d6945d074384f9819bc8e196ec717 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:01:03 +0300 Subject: [PATCH 24/30] fix(workflows): preserve permission route identity Persist the active permission-profile identity beside the compiled policy and restore both atomically for workflow workers and descendants. Agent: Nausicaa --- .../background_agent_live.rs | 136 ++++++++++++++++++ .../background_agent_processor.rs | 40 +++++- .../src/runtime/workflow_orchestrator.rs | 17 +++ 3 files changed, 188 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index b8b176fd5..2fa3b5012 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -89,6 +89,7 @@ use codex_background_agent::worker_admission::worker_admission_from_snapshot; use codex_core::NewThread; use codex_core::StartThreadOptions; use codex_core::config::ConfigOverrides; +use codex_core::config::PermissionProfileSnapshot; use codex_core::config::WorktreeCleanupMode as CoreWorktreeCleanupMode; use codex_core::config::WorktreeSessionMode as CoreWorktreeSessionMode; use codex_exec_server::LOCAL_FS; @@ -106,6 +107,7 @@ use codex_git_utils::validate_git_branch_name; use codex_git_utils::worktree_has_commits_after; use codex_protocol::approvals::ElicitationAction; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; @@ -3791,6 +3793,18 @@ async fn resolve_background_agent_config( .cloned() .map(serde_json::from_value::) .transpose()?; + let active_permission_profile = payload + .and_then(|payload| payload.get("activePermissionProfile")) + .filter(|value| !value.is_null()) + .cloned() + .map(serde_json::from_value::) + .transpose()?; + let permission_profile_snapshot = permission_profile.clone().map(|permission_profile| { + PermissionProfileSnapshot::from_session_snapshot( + permission_profile, + active_permission_profile, + ) + }); let default_permissions = payload .and_then(|payload| payload.get("permissionProfile")) .and_then(Value::as_str) @@ -3845,6 +3859,16 @@ async fn resolve_background_agent_config( .load_with_overrides(request_overrides, config_overrides) .await .map_err(anyhow::Error::from)?; + if let Some(permission_profile_snapshot) = permission_profile_snapshot { + config + .permissions + .replace_permission_profile_from_session_snapshot(permission_profile_snapshot) + .map_err(|err| { + anyhow::anyhow!( + "workflow_route_permission_profile_unavailable: failed to restore the admitted permission profile: {err}" + ) + })?; + } if config.selected_auth_profile != run.auth_profile_ref { anyhow::bail!( "{BACKGROUND_AGENT_ADMISSION_PROFILE_MISMATCH}: loaded worker auth profile does not match the admitted run" @@ -3944,6 +3968,16 @@ fn validate_background_agent_initial_execution_snapshot( "{BACKGROUND_AGENT_ADMISSION_SCHEMA_MISMATCH}: persisted execution snapshot configuration fingerprint does not match the admitted run" ); } + if let Some(active_permission_profile) = payload.get("activePermissionProfile") + && !active_permission_profile.is_null() + { + serde_json::from_value::(active_permission_profile.clone()) + .map_err(|err| { + anyhow::anyhow!( + "{BACKGROUND_AGENT_ADMISSION_SCHEMA_MISMATCH}: initial execution snapshot field activePermissionProfile is invalid: {err}" + ) + })?; + } if required_string("versionFingerprint")? != BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION || run.version_fingerprint.as_deref() != Some(BACKGROUND_AGENT_ADMISSION_SCHEMA_VERSION) { @@ -4014,6 +4048,10 @@ fn validate_workflow_route_snapshot( "workflow_route_receipt_invalid: workflow execution snapshot is not an object" ) })?; + validate_workflow_permission_profile_snapshot( + payload, + receipt.effective.permission_profile.as_deref(), + )?; let scalar_matches = |key: &str, expected: Option<&str>| { payload .get(key) @@ -4058,6 +4096,39 @@ fn validate_workflow_route_snapshot( Ok(Some(receipt)) } +fn validate_workflow_permission_profile_snapshot( + payload: &serde_json::Map, + expected_profile_id: Option<&str>, +) -> anyhow::Result<()> { + let active_permission_profile = payload + .get("activePermissionProfile") + .filter(|value| !value.is_null()) + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|err| { + anyhow::anyhow!( + "workflow_route_permission_profile_invalid: persisted active permission profile is invalid: {err}" + ) + })?; + if active_permission_profile.as_ref().map(|profile| profile.id.as_str()) + != expected_profile_id + { + anyhow::bail!( + "workflow_route_permission_profile_mismatch: persisted active permission profile differs from the immutable route receipt" + ); + } + if !payload + .get("permissionProfile") + .is_some_and(is_background_agent_core_permission_profile_value) + { + anyhow::bail!( + "workflow_route_permission_profile_unavailable: persisted compiled permission profile is missing" + ); + } + Ok(()) +} + fn enforce_workflow_route_config( run: &BackgroundAgentRun, snapshot: &BackgroundAgentExecutionSnapshot, @@ -5820,6 +5891,71 @@ mod tests { use std::sync::atomic::Ordering; use tempfile::TempDir; + #[test] + fn workflow_permission_profile_snapshot_keeps_compiled_profile_and_exact_identity() { + let payload = json!({ + "permissionProfile": PermissionProfile::read_only(), + "activePermissionProfile": {"id": ":read-only", "extends": null}, + }); + validate_workflow_permission_profile_snapshot( + payload.as_object().expect("payload should be an object"), + Some(":read-only"), + ) + .expect("matching compiled profile and identity should pass"); + } + + #[test] + fn workflow_permission_profile_snapshot_rejects_missing_or_mismatched_identity() { + let missing_identity = json!({ + "permissionProfile": PermissionProfile::read_only(), + }); + let missing_error = validate_workflow_permission_profile_snapshot( + missing_identity + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("missing identity must fail closed"); + assert!( + missing_error + .to_string() + .contains("workflow_route_permission_profile_mismatch") + ); + + let mismatched_identity = json!({ + "permissionProfile": PermissionProfile::read_only(), + "activePermissionProfile": {"id": ":workspace", "extends": null}, + }); + let mismatch_error = validate_workflow_permission_profile_snapshot( + mismatched_identity + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("mismatched identity must fail closed"); + assert!( + mismatch_error + .to_string() + .contains("workflow_route_permission_profile_mismatch") + ); + + let missing_compiled_profile = json!({ + "activePermissionProfile": {"id": ":read-only", "extends": null}, + }); + let missing_profile_error = validate_workflow_permission_profile_snapshot( + missing_compiled_profile + .as_object() + .expect("payload should be an object"), + Some(":read-only"), + ) + .expect_err("missing compiled profile must fail closed"); + assert!( + missing_profile_error + .to_string() + .contains("workflow_route_permission_profile_unavailable") + ); + } + #[test] fn descendant_worktree_mode_keeps_the_canonical_parent_route() { let shared_parent = workflow_descendant_worktree_mode( diff --git a/codex-rs/app-server/src/request_processors/background_agent_processor.rs b/codex-rs/app-server/src/request_processors/background_agent_processor.rs index 58883e881..dea782166 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_processor.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_processor.rs @@ -96,6 +96,7 @@ use codex_state::ManagedWorktreeAssignmentTarget; use codex_state::ManagedWorktreeAttachParams; use codex_state::ManagedWorktreeDetachParams; use codex_workflows::WorkflowRouteReceipt; +use serde_json::Value; use serde_json::json; use sha2::Digest; use sha2::Sha256; @@ -1236,8 +1237,13 @@ fn initial_execution_snapshot_payload( json!(receipt.effective.approval_policy), ); payload.insert( - "permissionProfile".to_string(), - json!(receipt.effective.permission_profile), + "activePermissionProfile".to_string(), + receipt + .effective + .permission_profile + .as_deref() + .map(|id| json!({"id": id, "extends": null})) + .unwrap_or(Value::Null), ); payload.insert("routeReceipt".to_string(), json!(receipt)); payload.insert( @@ -1896,6 +1902,7 @@ fn api_lifecycle_effect_from_runtime(effect: LifecycleEffect) -> AgentLifecycleE mod tests { use super::*; use chrono::Utc; + use codex_protocol::models::PermissionProfile; use codex_workflows::WorkflowEffectiveModelRoute; use codex_workflows::WorkflowModelRoute; use codex_workflows::WorkflowProviderCreditControl; @@ -1904,10 +1911,28 @@ mod tests { #[test] fn descendant_snapshot_persists_the_immutable_parent_route_receipt() { let receipt = test_workflow_route_receipt(); + let permission_profile = serde_json::to_value(PermissionProfile::read_only()) + .expect("read-only permission profile should serialize"); let payload = initial_execution_snapshot_payload(InitialExecutionSnapshotPayloadParams { cwd: Some("/tmp/worktree"), initial_goal_objective: None, - execution_context: None, + execution_context: Some(&AgentExecutionContextParams { + workspace_roots: Some(vec!["/tmp/worktree".to_string()]), + approval_policy: None, + permission_profile: Some(permission_profile.clone()), + sandbox_policy: None, + network_policy: None, + model: None, + provider: None, + service_tier: None, + mcp_tool_allowlist: None, + env_snapshot_policy: None, + shell_snapshot: None, + config_source_hashes: None, + max_runtime_seconds: None, + max_tokens: None, + recovery_policy: None, + }), recovery_policy: "abort_mid_turn_resume_at_safe_boundary", auth_profile_ref: Some("account007"), required_managed_worktree_id: Some("worktree-1"), @@ -1920,6 +1945,11 @@ mod tests { assert_eq!(payload.get("routeReceipt"), Some(&json!(receipt))); assert_eq!(payload.get("modelGateway"), Some(&json!("openrouter"))); assert_eq!(payload.get("reasoning"), Some(&json!("high"))); + assert_eq!(payload.get("permissionProfile"), Some(&permission_profile)); + assert_eq!( + payload.pointer("/activePermissionProfile/id"), + Some(&json!(":read-only")) + ); assert_eq!( payload.pointer("/workspace/mode"), Some(&json!("isolated_worktree")) @@ -2016,7 +2046,7 @@ mod tests { reasoning: "high".to_string(), service_tier: Some("priority".to_string()), approval_policy: Some("never".to_string()), - permission_profile: Some("workspace-write".to_string()), + permission_profile: Some(":read-only".to_string()), routing: None, }, effective: WorkflowEffectiveModelRoute { @@ -2027,7 +2057,7 @@ mod tests { service_tier: Some("priority".to_string()), auth_profile: Some("account007".to_string()), approval_policy: Some("never".to_string()), - permission_profile: Some("workspace-write".to_string()), + permission_profile: Some(":read-only".to_string()), worktree_mode: "isolated_worktree".to_string(), context_ceiling_tokens: Some(128_000), fallback_used: false, diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 5aeb7b6e4..536e45028 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1888,6 +1888,11 @@ fn branch_execution_payload( "serviceTier": route_receipt.effective.service_tier.as_deref(), "approvalPolicy": route_receipt.effective.approval_policy.as_deref(), "permissionProfile": params.permission_profile_json.clone(), + "activePermissionProfile": route_receipt + .effective + .permission_profile + .as_deref() + .map(|id| json!({"id": id, "extends": null})), "authProfileIdentitySha256": params .auth_profile_ref .as_deref() @@ -4254,6 +4259,18 @@ WHERE worktree_id = ? .expect("initial execution snapshot should include permissionProfile"); assert_eq!(&permission_profile_json, persisted_permission_profile); assert!(persisted_permission_profile.is_object()); + assert_eq!( + execution_snapshot + .payload_json + .pointer("/activePermissionProfile/id") + .and_then(Value::as_str), + branch + .route_receipt + .effective + .permission_profile + .as_deref(), + "the persisted compiled profile must retain its immutable active identity" + ); assert_ne!( branch.model_route_json.get("permission_profile"), Some(persisted_permission_profile), From 09ed9babcbaf5791562ed54e17a08573f991746f Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:03:59 +0300 Subject: [PATCH 25/30] style(workflows): match remote rustfmt Apply the exact line wrapping reported by the remote formatting gate. Agent: Nausicaa --- .../src/request_processors/background_agent_live.rs | 4 +++- codex-rs/state/src/runtime/workflow_orchestrator.rs | 6 +----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/background_agent_live.rs b/codex-rs/app-server/src/request_processors/background_agent_live.rs index 2fa3b5012..3eacb1434 100644 --- a/codex-rs/app-server/src/request_processors/background_agent_live.rs +++ b/codex-rs/app-server/src/request_processors/background_agent_live.rs @@ -4111,7 +4111,9 @@ fn validate_workflow_permission_profile_snapshot( "workflow_route_permission_profile_invalid: persisted active permission profile is invalid: {err}" ) })?; - if active_permission_profile.as_ref().map(|profile| profile.id.as_str()) + if active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()) != expected_profile_id { anyhow::bail!( diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 536e45028..9e55757f4 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -4264,11 +4264,7 @@ WHERE worktree_id = ? .payload_json .pointer("/activePermissionProfile/id") .and_then(Value::as_str), - branch - .route_receipt - .effective - .permission_profile - .as_deref(), + branch.route_receipt.effective.permission_profile.as_deref(), "the persisted compiled profile must retain its immutable active identity" ); assert_ne!( From b115af3a4a6fc4d40a34d8eb75361f7ed0090a6e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:17:33 +0300 Subject: [PATCH 26/30] fix(workflows): preserve verifier sandbox support Agent: Nausicaa --- .../app-server/tests/suite/v2/workflow.rs | 36 +++++++++++++++++ codex-rs/ext/workflows/src/activation.rs | 40 +++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index ffb954a41..88fa24b49 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -34,6 +34,8 @@ use codex_state::StateRuntime; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; +#[cfg(target_os = "linux")] +use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; use tokio::time::sleep; @@ -513,6 +515,22 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< )?; codex_workflows::parse_workflow_yaml(&yaml)?; + #[cfg(target_os = "linux")] + let bwrap_path = find_system_bwrap()?; + #[cfg(target_os = "linux")] + let mut mcp = TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[( + "CARGO_BIN_EXE_bwrap", + Some( + bwrap_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("bwrap path is not UTF-8"))?, + ), + )], + ) + .await?; + #[cfg(not(target_os = "linux"))] let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; initialize(&mut mcp, ExperimentalApiCapability::Enabled).await?; @@ -695,6 +713,24 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< Ok(()) } +#[cfg(target_os = "linux")] +fn find_system_bwrap() -> Result { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::var_os("PATH") + .ok_or_else(|| anyhow::anyhow!("PATH is unavailable while locating bwrap"))?; + for directory in std::env::split_paths(&path) { + let candidate = directory.join("bwrap"); + let Ok(metadata) = candidate.metadata() else { + continue; + }; + if metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 { + return Ok(candidate.canonicalize()?); + } + } + anyhow::bail!("system bwrap is unavailable on PATH") +} + #[tokio::test] async fn workflow_create_returns_sanitized_validation_error() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index f2798f6e6..fe412f25a 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -57,6 +57,8 @@ use serde::Deserialize; use serde::Serialize; use sha2::Digest; use sha2::Sha256; +#[cfg(target_os = "linux")] +use std::os::unix::fs::PermissionsExt; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; @@ -112,10 +114,24 @@ fn verifier_sandbox_support_env() -> HashMap { fn verifier_sandbox_support_env_from( mut read_var: impl FnMut(&str) -> Option, ) -> HashMap { - VERIFIER_SANDBOX_SUPPORT_ENV_VARS + let mut env: HashMap = VERIFIER_SANDBOX_SUPPORT_ENV_VARS .into_iter() .filter_map(|key| read_var(key).map(|value| (key.to_string(), value))) - .collect() + .collect(); + if let Some(bwrap_path) = env.get("CARGO_BIN_EXE_bwrap").map(PathBuf::from) + && bwrap_path.is_absolute() + && bwrap_path.file_name().is_some_and(|name| name == "bwrap") + && bwrap_path + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + && let Some(parent) = bwrap_path.parent() + { + env.insert( + "PATH".to_string(), + parent.as_os_str().to_string_lossy().into_owned(), + ); + } + env } #[cfg(not(target_os = "linux"))] @@ -1752,10 +1768,20 @@ cleanup: #[cfg(target_os = "linux")] #[test] fn verifier_sandbox_support_env_forwards_only_bazel_bwrap_runtime_paths() { + use std::fs; + + let temp_dir = tempfile::tempdir().expect("create bwrap directory"); + let bwrap_path = temp_dir.path().join("bwrap"); + fs::write(&bwrap_path, "#!/bin/sh\nexit 0\n").expect("write bwrap fixture"); + let mut permissions = fs::metadata(&bwrap_path) + .expect("read bwrap fixture metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&bwrap_path, permissions).expect("make bwrap fixture executable"); let ambient = HashMap::from([ ( "CARGO_BIN_EXE_bwrap".to_string(), - "/tmp/bazel-bin/codex-rs/bwrap/bwrap".to_string(), + bwrap_path.to_string_lossy().into_owned(), ), ( "RUNFILES_DIR".to_string(), @@ -1774,6 +1800,7 @@ cleanup: "UNRELATED_ENV".to_string(), "must-not-reach-verifier".to_string(), ), + ("PATH".to_string(), "/untrusted/ambient/path".to_string()), ]); let env = verifier_sandbox_support_env_from(|key| ambient.get(key).cloned()); @@ -1782,7 +1809,7 @@ cleanup: HashMap::from([ ( "CARGO_BIN_EXE_bwrap".to_string(), - "/tmp/bazel-bin/codex-rs/bwrap/bwrap".to_string(), + bwrap_path.to_string_lossy().into_owned(), ), ( "RUNFILES_DIR".to_string(), @@ -1797,10 +1824,15 @@ cleanup: "/tmp/app-server.runfiles/MANIFEST".to_string(), ), ("TEST_WORKSPACE".to_string(), "_main".to_string()), + ( + "PATH".to_string(), + temp_dir.path().to_string_lossy().into_owned(), + ), ]), env ); assert!(!env.contains_key("UNRELATED_ENV")); + assert_ne!(Some("/untrusted/ambient/path"), env.get("PATH").map(String::as_str)); } } From a53885693d1c2e1b0a693c5cc97bd1adf0b35a45 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:23:24 +0300 Subject: [PATCH 27/30] style(workflows): match remote rustfmt Agent: Nausicaa --- codex-rs/ext/workflows/src/activation.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index fe412f25a..4277c5589 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -1832,7 +1832,10 @@ cleanup: env ); assert!(!env.contains_key("UNRELATED_ENV")); - assert_ne!(Some("/untrusted/ambient/path"), env.get("PATH").map(String::as_str)); + assert_ne!( + Some("/untrusted/ambient/path"), + env.get("PATH").map(String::as_str) + ); } } From d4762c23deefd2356550f7c6fc8df81d7817cf17 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:54:05 +0300 Subject: [PATCH 28/30] test(app-server): preserve Bazel bwrap route Agent: Nausicaa --- .../app-server/tests/suite/v2/workflow.rs | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 88fa24b49..493792497 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -516,20 +516,23 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< codex_workflows::parse_workflow_yaml(&yaml)?; #[cfg(target_os = "linux")] - let bwrap_path = find_system_bwrap()?; - #[cfg(target_os = "linux")] - let mut mcp = TestAppServer::new_without_managed_config_with_env( - codex_home.path(), - &[( - "CARGO_BIN_EXE_bwrap", - Some( - bwrap_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("bwrap path is not UTF-8"))?, - ), - )], - ) - .await?; + let mut mcp = if std::env::var_os("CARGO_BIN_EXE_bwrap").is_some() { + TestAppServer::new_without_managed_config(codex_home.path()).await? + } else { + let bwrap_path = find_system_bwrap()?; + TestAppServer::new_without_managed_config_with_env( + codex_home.path(), + &[( + "CARGO_BIN_EXE_bwrap", + Some( + bwrap_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("bwrap path is not UTF-8"))?, + ), + )], + ) + .await? + }; #[cfg(not(target_os = "linux"))] let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; initialize(&mut mcp, ExperimentalApiCapability::Enabled).await?; From 065954545ee3d200d69af763cd062a00f22b898d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 03:08:31 +0300 Subject: [PATCH 29/30] test(app-server): expose workflow failure receipt Agent: Nausicaa --- codex-rs/app-server/tests/suite/v2/workflow.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 493792497..315049786 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -572,7 +572,8 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< assert_eq!( codex_state::WorkflowRunStatus::Completed, - snapshot.run.status + snapshot.run.status, + "workflow snapshot: {snapshot:#?}" ); assert_eq!(2, snapshot.steps.len()); assert!( From 744b9a5ddc31fc49509bd4c76866c7f8c17565a1 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 05:29:38 +0300 Subject: [PATCH 30/30] fix(workflows): align rebased review fixtures Agent: Nausicaa --- .../app-server/tests/suite/v2/workflow.rs | 26 ++++--- codex-rs/ext/workflows/src/activation.rs | 78 ++++++++++++++----- codex-rs/ext/workflows/src/manager_tool.rs | 52 +++++++++++-- .../src/runtime/workflow_orchestrator.rs | 59 +++++++------- 4 files changed, 151 insertions(+), 64 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/workflow.rs b/codex-rs/app-server/tests/suite/v2/workflow.rs index 315049786..a0b69ba4d 100644 --- a/codex-rs/app-server/tests/suite/v2/workflow.rs +++ b/codex-rs/app-server/tests/suite/v2/workflow.rs @@ -502,11 +502,12 @@ async fn workflow_start_activates_paired_real_workers_and_verifiers() -> Result< create_final_assistant_message_sse_response("workflow review done")?, ]) .await; - create_config_toml_with_sandbox_mode( + create_config_toml_with_sandbox_mode_and_permission_profile( codex_home.path(), &server.uri(), WorkflowsFeature::Enabled, "workspace-write", + ":workspace", )?; let thread_id = create_materialized_thread_with_cwd( codex_home.path(), @@ -904,14 +905,21 @@ fn create_config_toml( server_uri: &str, workflows_feature: WorkflowsFeature, ) -> std::io::Result<()> { - create_config_toml_with_sandbox_mode(codex_home, server_uri, workflows_feature, "read-only") + create_config_toml_with_sandbox_mode_and_permission_profile( + codex_home, + server_uri, + workflows_feature, + "read-only", + ":read-only", + ) } -fn create_config_toml_with_sandbox_mode( +fn create_config_toml_with_sandbox_mode_and_permission_profile( codex_home: &Path, server_uri: &str, workflows_feature: WorkflowsFeature, sandbox_mode: &str, + permission_profile: &str, ) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( @@ -922,7 +930,7 @@ model = "mock-model" model_reasoning_effort = "high" approval_policy = "never" sandbox_mode = "{sandbox_mode}" -permission_profile = ":read-only" +default_permissions = "{permission_profile}" model_provider = "mock_provider" suppress_unstable_features_warning = true @@ -1027,7 +1035,7 @@ execution_defaults: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: ":read-only" + permission_profile: ":workspace" limits: max_parallel_steps: 1 max_agents: 2 @@ -1048,7 +1056,7 @@ agents: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: ":read-only" + permission_profile: ":workspace" - id: "adversarial_reviewer" display_name: "Reviewer-Hypatia" role: "Independently review the exact activation candidate." @@ -1058,7 +1066,7 @@ agents: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "workspace-write" + permission_profile: ":workspace" steps: - id: "actual_worker" title: "Run the actual workflow worker" @@ -1069,7 +1077,7 @@ steps: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: ":read-only" + permission_profile: ":workspace" workspace: mode: "isolated_worktree" depends_on: [] @@ -1096,7 +1104,7 @@ steps: model: "mock-model" reasoning: "high" approval_policy: "never" - permission_profile: "workspace-write" + permission_profile: ":workspace" workspace: mode: "isolated_worktree" depends_on: diff --git a/codex-rs/ext/workflows/src/activation.rs b/codex-rs/ext/workflows/src/activation.rs index 4277c5589..220ba3a76 100644 --- a/codex-rs/ext/workflows/src/activation.rs +++ b/codex-rs/ext/workflows/src/activation.rs @@ -126,10 +126,14 @@ fn verifier_sandbox_support_env_from( .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) && let Some(parent) = bwrap_path.parent() { - env.insert( - "PATH".to_string(), - parent.as_os_str().to_string_lossy().into_owned(), - ); + let mut path_entries = read_var("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + path_entries.retain(|entry| entry != parent); + path_entries.insert(0, parent.to_path_buf()); + if let Ok(path) = std::env::join_paths(path_entries) { + env.insert("PATH".to_string(), path.to_string_lossy().into_owned()); + } } env } @@ -1616,7 +1620,7 @@ execution_defaults: reasoning: "high" limits: max_parallel_steps: 1 - max_agents: 1 + max_agents: 2 max_worktrees: 1 max_runtime_seconds: 300 max_step_runtime_seconds: 120 @@ -1625,9 +1629,17 @@ limits: approvals: required_before: [] agents: - - id: "adversarial_worker" - display_name: "Adversary-Hypatia" - role: "Adversarially prove route activation." + - id: "candidate_worker" + display_name: "Builder-Vitruvius" + role: "Build the exact route activation candidate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + - id: "adversarial_reviewer" + display_name: "Reviewer-Hypatia" + role: "Independently review the route activation candidate." model: model_gateway: "hasna" provider: "openai" @@ -1636,7 +1648,7 @@ agents: steps: - id: "run" title: "Run exact route" - agent: "adversarial_worker" + agent: "candidate_worker" model: model_gateway: "hasna" provider: "openai" @@ -1653,10 +1665,37 @@ steps: artifact: "result.md" must_contain: - "done" + - id: "initial_adversarial_review" + title: "Run the initial adversarial review" + agent: "adversarial_reviewer" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + depends_on: + - "run" + outputs: + - "review.yaml" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "finite_review_artifact_contract" + type: "artifact_contains" + artifact: "review.yaml" + must_contain: + - "candidate_identity:" + - "acceptance_criteria:" + - "verdict:" + - "blocking_p0_p1:" + - "non_blocking_p2_p3:" + - "remediation_cycle:" + - "remediation_cycle_cap: 2" artifacts: retention: "until_workflow_complete" required: - "result.md" + - "review.yaml" cleanup: on_cancel: [] on_complete: [] @@ -1767,7 +1806,7 @@ cleanup: #[cfg(target_os = "linux")] #[test] - fn verifier_sandbox_support_env_forwards_only_bazel_bwrap_runtime_paths() { + fn verifier_sandbox_support_env_forwards_bazel_runtime_and_command_paths() { use std::fs; let temp_dir = tempfile::tempdir().expect("create bwrap directory"); @@ -1800,10 +1839,18 @@ cleanup: "UNRELATED_ENV".to_string(), "must-not-reach-verifier".to_string(), ), - ("PATH".to_string(), "/untrusted/ambient/path".to_string()), + ("PATH".to_string(), "/trusted/tooling:/usr/bin".to_string()), ]); let env = verifier_sandbox_support_env_from(|key| ambient.get(key).cloned()); + let expected_path = std::env::join_paths([ + temp_dir.path().to_path_buf(), + PathBuf::from("/trusted/tooling"), + PathBuf::from("/usr/bin"), + ]) + .expect("join expected verifier command path") + .to_string_lossy() + .into_owned(); assert_eq!( HashMap::from([ @@ -1824,18 +1871,11 @@ cleanup: "/tmp/app-server.runfiles/MANIFEST".to_string(), ), ("TEST_WORKSPACE".to_string(), "_main".to_string()), - ( - "PATH".to_string(), - temp_dir.path().to_string_lossy().into_owned(), - ), + ("PATH".to_string(), expected_path), ]), env ); assert!(!env.contains_key("UNRELATED_ENV")); - assert_ne!( - Some("/untrusted/ambient/path"), - env.get("PATH").map(String::as_str) - ); } } diff --git a/codex-rs/ext/workflows/src/manager_tool.rs b/codex-rs/ext/workflows/src/manager_tool.rs index 18f7f8314..c07af3d57 100644 --- a/codex-rs/ext/workflows/src/manager_tool.rs +++ b/codex-rs/ext/workflows/src/manager_tool.rs @@ -667,7 +667,7 @@ execution_defaults: permission_profile: "read-only" limits: max_parallel_steps: 1 - max_agents: 1 + max_agents: 2 max_worktrees: 1 max_runtime_seconds: 300 max_step_runtime_seconds: 120 @@ -677,9 +677,19 @@ approvals: required_before: - "production_deploy" agents: + - id: "candidate_operator" + display_name: "Builder-Vitruvius" + role: "Exercise the workflow lifecycle gate." + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" - id: "adversarial_reviewer" - display_name: "Adversary-Hypatia" - role: "Adversarially exercise the workflow lifecycle gate." + display_name: "Reviewer-Hypatia" + role: "Independently review the workflow lifecycle candidate." model: model_gateway: "hasna" provider: "openai" @@ -690,7 +700,7 @@ agents: steps: - id: "launch_gate" title: "Hold at the explicit launch gate" - agent: "adversarial_reviewer" + agent: "candidate_operator" model: model_gateway: "hasna" provider: "openai" @@ -710,10 +720,39 @@ steps: artifact: "result.md" must_contain: - "done" + - id: "initial_adversarial_review" + title: "Run the initial adversarial review" + agent: "adversarial_reviewer" + model: + model_gateway: "hasna" + provider: "openai" + model: "gpt-5.4" + reasoning: "high" + approval_policy: "never" + permission_profile: "read-only" + depends_on: + - "launch_gate" + outputs: + - "review.yaml" + completion: + model_marked_state: "candidate_succeeded" + verifiers: + - id: "finite_review_artifact_contract" + type: "artifact_contains" + artifact: "review.yaml" + must_contain: + - "candidate_identity:" + - "acceptance_criteria:" + - "verdict:" + - "blocking_p0_p1:" + - "non_blocking_p2_p3:" + - "remediation_cycle:" + - "remediation_cycle_cap: 2" artifacts: retention: "until_workflow_complete" required: - "result.md" + - "review.yaml" cleanup: on_cancel: [] on_complete: [] @@ -742,8 +781,7 @@ cleanup: .expect("thread metadata should insert"); let tool = ManageWorkflowTool::new(Arc::new(AtomicBool::new(true)), state_db.clone(), thread_id); - let unbudgeted_yaml = - DENTAL_LEAD_SAAS_WORKFLOW_EXAMPLE_YAML.replace(" max_tokens: 2500000", ""); + let unbudgeted_yaml = MANAGE_WORKFLOW_TEST_YAML.replace(" max_tokens: 1000", ""); assert!( !unbudgeted_yaml.contains("max_tokens:"), "unbudgeted workflow fixture must not retain the token limit key" @@ -927,7 +965,7 @@ cleanup: .expect("run id") .to_string(); - // Approving the dental example's gated launch step records an approved + // Approving the fixture's gated launch step records an approved // decision without leaking the raw reason. let approve = call_tool( &tool, diff --git a/codex-rs/state/src/runtime/workflow_orchestrator.rs b/codex-rs/state/src/runtime/workflow_orchestrator.rs index 9e55757f4..02e2c8f7c 100644 --- a/codex-rs/state/src/runtime/workflow_orchestrator.rs +++ b/codex-rs/state/src/runtime/workflow_orchestrator.rs @@ -1380,12 +1380,14 @@ async fn create_background_branch_run_if_missing_in_tx( tx: &mut sqlx::Transaction<'_, Sqlite>, branch: BackgroundBranchRunCreate<'_>, ) -> anyhow::Result { + let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; + let execution_payload = branch_execution_payload(&branch, recovery_policy); let BackgroundBranchRunCreate { run, candidate, attempt, route_receipt, - workspace_json, + workspace_json: _, provisioned_workspace, review_context, background_agent_run_id, @@ -1417,17 +1419,6 @@ async fn create_background_branch_run_if_missing_in_tx( "agentId": candidate.agent_id.as_str(), "parallelGroup": candidate.parallel_group.as_deref(), }); - let recovery_policy = WORKFLOW_BRANCH_RECOVERY_POLICY; - let execution_payload = branch_execution_payload( - run, - candidate, - workspace_json, - provisioned_workspace, - review_context, - params, - recovery_policy, - route_receipt, - ); let run_params = BackgroundAgentRunCreateParams { id: background_agent_run_id.to_string(), idempotency_key: Some(idempotency_key.to_string()), @@ -1863,15 +1854,14 @@ fn workflow_review_branch_context( } fn branch_execution_payload( - run: &crate::WorkflowRun, - candidate: &ReadyBranchCandidate, - workspace_json: Option<&Value>, - provisioned_workspace: &ProvisionedWorkflowWorkspace, - review_context: Option<&WorkflowReviewBranchContext>, - params: &WorkflowRunBranchAdmissionParams, + branch: &BackgroundBranchRunCreate<'_>, recovery_policy: &str, - route_receipt: &WorkflowRouteReceipt, ) -> Value { + let run = branch.run; + let candidate = branch.candidate; + let route_receipt = branch.route_receipt; + let provisioned_workspace = branch.provisioned_workspace; + let params = branch.params; json!({ "snapshotSource": "workflow/branch_admission", "workflowRunId": run.run_id.as_str(), @@ -1897,8 +1887,8 @@ fn branch_execution_payload( .auth_profile_ref .as_deref() .map(|profile| StateRuntime::background_agent_identity_sha256(profile.as_bytes())), - "workspace": workspace_json, - "reviewContext": review_context.map(|context| &context.state_json), + "workspace": branch.workspace_json, + "reviewContext": branch.review_context.map(|context| &context.state_json), "envSnapshotPolicy": "inherit-minimal", "configFingerprint": params.config_fingerprint, "versionFingerprint": params.version_fingerprint, @@ -4526,7 +4516,7 @@ WHERE worktree_id = ? "wf_branch_openrouter_missing_env", /*step_count*/ 1, /*max_parallel_steps*/ 1, - /*max_agents*/ 1, + /*max_agents*/ 2, /*max_worktrees*/ 1, "missing-openrouter-env", ), @@ -4604,12 +4594,20 @@ WHERE worktree_id = ? }) .collect::>(); assert_eq!( - vec![( - "branch_0".to_string(), - crate::WorkflowRunStepStatus::Blocked, - Some(WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string()), - None, - )], + vec![ + ( + "branch_0".to_string(), + crate::WorkflowRunStepStatus::Blocked, + Some(WORKFLOW_BRANCH_PROVIDER_ENV_MISSING_REASON_CODE.to_string()), + None, + ), + ( + "initial_adversarial_review".to_string(), + crate::WorkflowRunStepStatus::Pending, + None, + None, + ), + ], step_states ); let branch_verifier = admitted @@ -4680,7 +4678,10 @@ WHERE worktree_id = ? assert_eq!(projection.plan_id, plan.plan.plan_id); assert_eq!(crate::ThreadGoalPlanStatus::Blocked, plan.plan.status); assert_eq!( - vec![crate::ThreadGoalPlanNodeStatus::Blocked], + vec![ + crate::ThreadGoalPlanNodeStatus::Blocked, + crate::ThreadGoalPlanNodeStatus::Blocked, + ], plan.nodes .iter() .map(|node| node.status)