Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/decodex/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
147 changes: 145 additions & 2 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -119,7 +129,7 @@ pub(crate) struct AppServerCapabilityPreflightReport {
checks: Vec<AppServerCapabilityPreflightCheck>,
}
impl AppServerCapabilityPreflightReport {
fn new() -> Self {
pub(crate) fn new() -> Self {
Self { checks: Vec::new() }
}

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2171,6 +2224,7 @@ fn run_app_server_capability_preflight(
client: &mut AppServerClient,
recorder: &mut RunRecorder<'_>,
cwd: &str,
user_agent: &str,
) -> crate::prelude::Result<AppServerCapabilityPreflightReport> {
let mut report = AppServerCapabilityPreflightReport::new();
let config = preflight_request(recorder, &report, "config/read", || {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String> {
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<String> {
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::<String>();

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::<Vec<_>>()
.join(", ")
}

fn record_app_server_preflight_report(
recorder: &mut RunRecorder<'_>,
report: &AppServerCapabilityPreflightReport,
Expand Down
82 changes: 78 additions & 4 deletions apps/decodex/src/agent/app_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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::<Vec<_>>();
let executable_versions = SUPPORTED_CODEX_CLI_VERSION_DISPLAY_ORDER
.iter()
.map(|version| (*version).to_owned())
.collect::<Vec<_>>();

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");
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions apps/decodex/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::agent::AppServerRunResult;
use crate::agent::{AppServerCapabilityPreflightReport, AppServerRunResult};

#[test]
fn completed_issue_thread_archive_candidates_include_prior_terminal_attempts() {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions docs/spec/app-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
Loading