diff --git a/apps/decodex/src/agent.rs b/apps/decodex/src/agent.rs index 4517822..2dd32c9 100644 --- a/apps/decodex/src/agent.rs +++ b/apps/decodex/src/agent.rs @@ -4,6 +4,7 @@ mod decodex_tool_bridge; mod json_rpc; mod tracker_tool_bridge; +#[cfg(test)] pub(crate) use self::app_server::AppServerCapabilityPreflightReport; #[cfg(test)] pub(crate) use self::app_server::MODEL_EXECUTION_IDLE_TIMEOUT; #[cfg(test)] pub(crate) use self::tracker_tool_bridge::DynamicToolHandler; pub(crate) use self::{ diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index 54c8b7e..0be81a8 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -78,7 +78,17 @@ const PREFLIGHT_CHECK_MODEL_PROVIDER: &str = "model_provider"; const PREFLIGHT_CHECK_SKILLS: &str = "skills"; const PREFLIGHT_CHECK_PLUGINS: &str = "plugins"; const PREFLIGHT_CHECK_MCP: &str = "mcp"; +const PREFLIGHT_CHECK_COMPATIBILITY: &str = "compatibility"; const PREFLIGHT_PLUGIN_MARKETPLACE_KIND: &str = "local"; +const APP_SERVER_COMPATIBILITY_SUPPORT_CLAIM: &str = "local_verified_codex_cli_0.136"; +const APP_SERVER_COMPATIBILITY_EVIDENCE: &str = + "initialize.userAgent plus successful app-server capability preflight"; +const CODEX_CLI_VERSION_STABLE_0_136_0: &str = "0.136.0"; +const CODEX_CLI_VERSION_BETA_0_136_0_ALPHA_2: &str = "0.136.0-alpha.2"; +const SUPPORTED_CODEX_CLI_VERSION_MATCH_ORDER: &[&str] = + &[CODEX_CLI_VERSION_BETA_0_136_0_ALPHA_2, CODEX_CLI_VERSION_STABLE_0_136_0]; +const SUPPORTED_CODEX_CLI_VERSION_DISPLAY_ORDER: &[&str] = + &[CODEX_CLI_VERSION_STABLE_0_136_0, CODEX_CLI_VERSION_BETA_0_136_0_ALPHA_2]; const JSONRPC_METHOD_NOT_FOUND: i64 = -32_601; const CHILD_BUCKET_MODEL: &str = "Model"; const WAITING_REASON_MODEL_EXECUTION: &str = "model_execution"; @@ -119,7 +129,7 @@ pub(crate) struct AppServerCapabilityPreflightReport { checks: Vec, } impl AppServerCapabilityPreflightReport { - fn new() -> Self { + pub(crate) fn new() -> Self { Self { checks: Vec::new() } } @@ -170,6 +180,30 @@ impl AppServerCapabilityPreflightReport { if blockers.is_empty() { String::from("no blockers recorded") } else { blockers.join("; ") } } + + pub(crate) fn compatibility_status(&self) -> &'static str { + match self.compatibility_check().map(|check| check.status) { + Some(AppServerCapabilityPreflightStatus::Ok) => "supported", + Some(AppServerCapabilityPreflightStatus::Blocked) => "unsupported", + None => "not_checked", + } + } + + pub(crate) fn compatibility_codex_cli_version(&self) -> Option<&str> { + self.compatibility_detail("codex_cli_version") + } + + pub(crate) fn compatibility_supported_versions(&self) -> Option<&str> { + self.compatibility_detail("supported_versions") + } + + fn compatibility_check(&self) -> Option<&AppServerCapabilityPreflightCheck> { + self.checks.iter().find(|check| check.name == PREFLIGHT_CHECK_COMPATIBILITY) + } + + fn compatibility_detail(&self, detail: &str) -> Option<&str> { + self.compatibility_check().and_then(|check| check.details.get(detail)).map(String::as_str) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -437,6 +471,7 @@ impl CommandExecHealthCheck { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct AppServerRunResult { pub(crate) user_agent: String, + pub(crate) capability_preflight: AppServerCapabilityPreflightReport, pub(crate) thread_id: String, pub(crate) turn_id: String, pub(crate) turn_count: u32, @@ -1084,6 +1119,16 @@ fn preflight_check_blocker_summary(check: &AppServerCapabilityPreflightCheck) -> summary.push_str("; first_error="); summary.push_str(error); } + if check.name == PREFLIGHT_CHECK_COMPATIBILITY { + for detail_name in ["codex_cli_version", "user_agent", "supported_versions"] { + if let Some(detail) = check.details.get(detail_name) { + summary.push(' '); + summary.push_str(detail_name); + summary.push('='); + summary.push_str(detail); + } + } + } summary } @@ -2083,7 +2128,14 @@ fn execute_app_server_run_inner( client.mark_initialized()?; write_capability_preflight_marker_best_effort(request); - run_app_server_capability_preflight(&mut client, &mut recorder, &request.cwd)?; + + let capability_preflight = run_app_server_capability_preflight( + &mut client, + &mut recorder, + &request.cwd, + &initialize_response.user_agent, + )?; + write_activity_marker_best_effort_for_request(request); if let Some(health_check) = request.command_exec_health_check.as_ref() { @@ -2128,6 +2180,7 @@ fn execute_app_server_run_inner( Ok(AppServerRunResult { user_agent: initialize_response.user_agent, + capability_preflight, thread_id, turn_id: turn_result.turn_id, turn_count: turn_result.turn_count, @@ -2171,6 +2224,7 @@ fn run_app_server_capability_preflight( client: &mut AppServerClient, recorder: &mut RunRecorder<'_>, cwd: &str, + user_agent: &str, ) -> crate::prelude::Result { let mut report = AppServerCapabilityPreflightReport::new(); let config = preflight_request(recorder, &report, "config/read", || { @@ -2228,6 +2282,10 @@ fn run_app_server_capability_preflight( }, } + if !report.has_blockers() { + record_app_server_compatibility_guard(&mut report, user_agent); + } + record_app_server_preflight_report(recorder, &report)?; if report.has_blockers() { @@ -2645,6 +2703,91 @@ fn record_mcp_preflight_degraded(report: &mut AppServerCapabilityPreflightReport ); } +fn record_app_server_compatibility_guard( + report: &mut AppServerCapabilityPreflightReport, + user_agent: &str, +) { + let codex_cli_version = codex_cli_version_from_user_agent(user_agent); + let mut details = BTreeMap::new(); + + details.insert(String::from("user_agent"), user_agent.to_owned()); + details.insert(String::from("supported_versions"), supported_codex_cli_versions_display()); + details + .insert(String::from("support_claim"), APP_SERVER_COMPATIBILITY_SUPPORT_CLAIM.to_owned()); + details.insert(String::from("evidence"), APP_SERVER_COMPATIBILITY_EVIDENCE.to_owned()); + + if let Some(version) = codex_cli_version.as_deref() { + details.insert(String::from("codex_cli_version"), format!("codex-cli {version}")); + } + if let Some(version) = supported_codex_cli_version_from_user_agent(user_agent) { + details.insert(String::from("matched_supported_version"), format!("codex-cli {version}")); + report.push_ok( + PREFLIGHT_CHECK_COMPATIBILITY, + "app-server userAgent is within the locally verified Codex CLI capability range.", + details, + ); + } else { + report.push_blocked( + PREFLIGHT_CHECK_COMPATIBILITY, + "app-server userAgent is outside the locally verified Codex CLI capability range.", + details, + ); + } +} + +fn supported_codex_cli_version_from_user_agent(user_agent: &str) -> Option<&'static str> { + let codex_cli_version = codex_cli_version_from_user_agent(user_agent)?; + + SUPPORTED_CODEX_CLI_VERSION_MATCH_ORDER + .iter() + .copied() + .find(|version| codex_cli_version == *version) +} + +fn codex_cli_version_from_user_agent(user_agent: &str) -> Option { + let lower_user_agent = user_agent.to_ascii_lowercase(); + + if let Some(marker_start) = lower_user_agent.find("codex-cli") { + let marker_end = marker_start + "codex-cli".len(); + + return user_agent_version_token(&user_agent[marker_end..]); + } + + let first_token = user_agent.split_whitespace().next()?; + let (product, version) = first_token.rsplit_once('/')?; + + if !product.to_ascii_lowercase().contains("codex") { + return None; + } + + user_agent_version_token(version) +} + +fn user_agent_version_token(value: &str) -> Option { + let version = value + .trim_start_matches(|character: char| { + character.is_whitespace() || character == '/' || character == ':' + }) + .chars() + .take_while(|character| { + character.is_ascii_alphanumeric() + || *character == '.' + || *character == '-' + || *character == '_' + }) + .collect::(); + + version.chars().next().is_some_and(|character| character.is_ascii_digit()).then_some(version) +} + +fn supported_codex_cli_versions_display() -> String { + SUPPORTED_CODEX_CLI_VERSION_DISPLAY_ORDER + .iter() + .map(|version| format!("codex-cli {version}")) + .collect::>() + .join(", ") +} + fn record_app_server_preflight_report( recorder: &mut RunRecorder<'_>, report: &AppServerCapabilityPreflightReport, diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 12a53ec..138d17c 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -19,8 +19,8 @@ use crate::{ AppServerTurnFailure, CommandExecHealthCheck, CommandExecResponse, EffectiveThreadConfig, InitializeResponse, ModelProviderCapabilitiesReadResponse, PluginListResponse, ProbeDynamicToolHandler, REQUEST_TIMEOUT, RequestWaitPhase, - RunRecorder, RuntimeConfigSummary, SkillsListResponse, TurnContinuationGuard, - UserInput, + RunRecorder, RuntimeConfigSummary, SUPPORTED_CODEX_CLI_VERSION_DISPLAY_ORDER, + SkillsListResponse, TurnContinuationGuard, UserInput, }, json_rpc::{ AppServerHomePreflightFailure, AppServerOutputTimeout, AppServerProcessEnv, @@ -268,6 +268,7 @@ fn matches_thread_id_from_supported_notification_shapes() { fn probe_result_shape_is_stable() { let result = AppServerRunResult { user_agent: String::from("ua"), + capability_preflight: AppServerCapabilityPreflightReport::new(), thread_id: String::from("thread"), turn_id: String::from("turn"), turn_count: 1, @@ -280,6 +281,79 @@ fn probe_result_shape_is_stable() { assert_eq!(result.turn_count, 1); } +#[test] +fn app_server_compatibility_guard_accepts_current_verified_codex_surfaces() { + for (user_agent, expected_codex_cli_version) in [ + ("codex-cli 0.136.0", "codex-cli 0.136.0"), + ("codex-cli 0.136.0-alpha.2", "codex-cli 0.136.0-alpha.2"), + ("decodex/0.136.0 (Mac OS 26.5.0; arm64) unknown (decodex; 0.1.0)", "codex-cli 0.136.0"), + ( + "decodex/0.136.0-alpha.2 (Mac OS 26.5.0; arm64) unknown (decodex; 0.1.0)", + "codex-cli 0.136.0-alpha.2", + ), + ] { + let mut report = AppServerCapabilityPreflightReport::new(); + + super::record_app_server_compatibility_guard(&mut report, user_agent); + + assert!(!report.has_blockers(), "{user_agent} should be supported"); + assert_eq!(report.compatibility_status(), "supported"); + assert_eq!( + report.compatibility_codex_cli_version(), + Some(expected_codex_cli_version), + "{user_agent} should be the matched Codex CLI version" + ); + assert_eq!( + report.compatibility_supported_versions(), + Some("codex-cli 0.136.0, codex-cli 0.136.0-alpha.2") + ); + } +} + +#[test] +fn app_server_compatibility_guard_rejects_unverified_codex_surfaces() { + for user_agent in [ + "codex-cli 0.137.0-alpha.0", + "codex-cli 0.136.1", + "other-app/0.136.0", + "openai/codex upstream-main-post-rust-v0.136.0", + ] { + let mut report = AppServerCapabilityPreflightReport::new(); + + super::record_app_server_compatibility_guard(&mut report, user_agent); + + assert!(report.has_blockers(), "{user_agent} should be outside support"); + assert_eq!(report.compatibility_status(), "unsupported"); + assert_eq!(report.checks()[0].name, "compatibility"); + assert_eq!(report.checks()[0].status, super::AppServerCapabilityPreflightStatus::Blocked); + assert!(report.checks()[0].summary.contains("outside")); + } +} + +#[test] +fn app_server_compatibility_versions_match_spec_table() { + let spec_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("docs") + .join("spec") + .join("app-server.md"); + let spec = fs::read_to_string(spec_path).expect("app-server spec should be readable"); + let documented_versions = spec + .lines() + .filter(|line| line.starts_with("| ") && line.contains("`codex-cli ")) + .filter_map(|line| line.split('`').find_map(|segment| segment.strip_prefix("codex-cli "))) + .map(str::to_owned) + .collect::>(); + let executable_versions = SUPPORTED_CODEX_CLI_VERSION_DISPLAY_ORDER + .iter() + .map(|version| (*version).to_owned()) + .collect::>(); + + assert_eq!(documented_versions, executable_versions); + assert!(spec.contains("Upstream `main` commits after `rust-v0.136.0` are outside")); +} + #[test] fn turn_start_request_uses_default_runtime_settings() { let request = super::build_turn_start_request("thread-1", "hello"); @@ -484,7 +558,7 @@ for line in sys.stdin: if method == "initialize": reply({ - "userAgent": "fake-codex", + "userAgent": "codex-cli 0.136.0", "codexHome": os.environ["CODEX_HOME"], "platformFamily": "unix", "platformOs": "macos" @@ -592,7 +666,7 @@ for line in sys.stdin: print(json.dumps({{ "id": message["id"], "result": {{ - "userAgent": "fake-codex", + "userAgent": "codex-cli 0.136.0", "codexHome": os.environ["CODEX_HOME"], "platformFamily": "unix", "platformOs": "macos" diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index ecdf08b..dee43a0 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -1095,8 +1095,14 @@ impl ProbeCommand { let report = agent::probe_app_server(&self.transport)?; println!( - "probe ok: thread={} turn={} events={} output={}", - report.thread_id, report.turn_id, report.event_count, report.final_output + "probe ok: compatibility={} codex_version={} supported_versions=\"{}\" thread={} turn={} events={} output={}", + report.capability_preflight.compatibility_status(), + report.capability_preflight.compatibility_codex_cli_version().unwrap_or("unknown"), + report.capability_preflight.compatibility_supported_versions().unwrap_or("unknown"), + report.thread_id, + report.turn_id, + report.event_count, + report.final_output ); tracing::info!( diff --git a/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs b/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs index 77cd39e..28cd0e7 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs @@ -1,4 +1,4 @@ -use crate::agent::AppServerRunResult; +use crate::agent::{AppServerCapabilityPreflightReport, AppServerRunResult}; #[test] fn completed_issue_thread_archive_candidates_include_prior_terminal_attempts() { @@ -54,6 +54,7 @@ fn completed_issue_thread_archive_candidates_include_prior_terminal_attempts() { }; let run_result = AppServerRunResult { user_agent: String::from("codex-test"), + capability_preflight: AppServerCapabilityPreflightReport::new(), thread_id: String::from("thread-current"), turn_id: String::from("turn-current"), turn_count: 1, diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index 8c92622..4a50501 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -50,6 +50,8 @@ range only when all of these are true: - `decodex probe stdio://` completes the app-server capability preflight, standalone `command/exec` health check, and dynamic-tool round trip with `PROBE_OK`. +- The executable Decodex compatibility guard reports `compatibility=supported` for + the initialized app-server `userAgent` after the capability preflight succeeds. As of the 2026-06-02 self-compatibility pass, the verified local range is: @@ -74,6 +76,13 @@ The previous 2026-05 local refresh covered `codex-cli 0.132.0-alpha.1` from `PAT and the Codex Beta app bundle's `codex-cli 0.131.0-alpha.9`. Treat those as historical compatibility evidence, not the current upgrade target. +`decodex probe stdio://` exposes the executable guard in its success line, including +`compatibility=supported`, the observed `codex_version`, and the executable +`supported_versions` list. During retained-lane dispatch, the same compatibility check +runs after the bounded capability preflight and before `thread/start` or +`thread/resume`; an app-server identity outside the locally verified list is a +pre-dispatch app-server preflight blocker rather than a promptable agent turn. + Current upstream Codex signals are beyond the local support claim whenever they are newer than the latest locally probed version, or when checked-in Radar queue entries flag app-server protocol, plugin metadata, dynamic tool, sandbox/config, GitHub/Linear diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index aba359f..75f7c7a 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -499,9 +499,10 @@ After a process restart, recent-run history, active lease ownership, retained po - When a queued retry becomes due, `decodex` must refresh that exact issue, redispatch it only if it is still active under retry policy, and otherwise release the queued claim. - Before a prepared lane starts `app-server`, `decodex` must refresh the selected issue once more and skip execution if the issue became terminal or otherwise ineligible. - After `app-server` initializes and before `thread/start` or `thread/resume`, `decodex` - must run the bounded app-server capability preflight defined in + must run the bounded app-server capability and compatibility preflight defined in [`app-server.md`](./app-server.md). Missing config/model/provider/skills/plugin/MCP - state is a pre-dispatch terminal blocker with an operator-readable error class, - not a promptable agent turn. + state, or an app-server identity outside the locally verified compatibility range, is + a pre-dispatch terminal blocker with an operator-readable error class, not a + promptable agent turn. - If the local process crashed during a run, `decodex` must recover from the runtime database, current tracker cache or state, and retained worktree inspection. - If Linear shows a non-terminal state but no local lease exists, the issue may become eligible again after reconciliation or may be redispatched through the retained recovered worktree.