From 546dcbf0a4364044722969ddd0e56470cf4ac6ff Mon Sep 17 00:00:00 2001 From: Cooper Gamble Date: Wed, 3 Jun 2026 00:32:59 +0000 Subject: [PATCH] [codex-app-server] add native integrity state bridge [ci changed_files] --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 18 ++ .../src/protocol/v2/mod.rs | 2 + .../src/protocol/v2/native_integrity_state.rs | 33 ++++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 3 + codex-rs/app-server/src/message_processor.rs | 17 ++ codex-rs/app-server/src/request_processors.rs | 2 + .../native_integrity_state_processor.rs | 93 +++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/native_integrity_state.rs | 181 ++++++++++++++++++ 11 files changed, 352 insertions(+) create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/native_integrity_state.rs create mode 100644 codex-rs/app-server/src/request_processors/native_integrity_state_processor.rs create mode 100644 codex-rs/app-server/tests/suite/v2/native_integrity_state.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b4b950e7dd..c98446e3f2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1913,6 +1913,7 @@ dependencies = [ "codex-arg0", "codex-backend-client", "codex-chatgpt", + "codex-client", "codex-cloud-config", "codex-config", "codex-core", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 961a4a227c..f4e53e3dbf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -849,6 +849,24 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("nativeIntegrityState/read")] + NativeIntegrityStateRead => "nativeIntegrityState/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("native-integrity-state"), + response: v2::NativeIntegrityStateReadResponse, + }, + #[experimental("nativeIntegrityState/write")] + NativeIntegrityStateWrite => "nativeIntegrityState/write" { + params: v2::NativeIntegrityStateWriteParams, + serialization: global("native-integrity-state"), + response: v2::NativeIntegrityStateWriteResponse, + }, + #[experimental("nativeIntegrityState/clear")] + NativeIntegrityStateClear => "nativeIntegrityState/clear" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("native-integrity-state"), + response: v2::NativeIntegrityStateClearResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index b5fa9fdc65..5a1931a508 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -14,6 +14,7 @@ mod hook; mod item; mod mcp; mod model; +mod native_integrity_state; mod notification; mod permissions; mod plugin; @@ -40,6 +41,7 @@ pub use hook::*; pub use item::*; pub use mcp::*; pub use model::*; +pub use native_integrity_state::*; pub use notification::*; pub use permissions::*; pub use plugin::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/native_integrity_state.rs b/codex-rs/app-server-protocol/src/protocol/v2/native_integrity_state.rs new file mode 100644 index 0000000000..03309df25f --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/native_integrity_state.rs @@ -0,0 +1,33 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NativeIntegrityStateReadResponse { + pub state: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NativeIntegrityStateWriteParams { + pub state: String, + /// When present, write only if the calling surface still stores this state. + #[ts(optional = nullable)] + pub expected_state: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NativeIntegrityStateWriteResponse { + pub written: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NativeIntegrityStateClearResponse {} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 37cdf219bf..30895a7f6c 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -53,6 +53,7 @@ codex-utils-pty = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } +codex-client = { workspace = true } codex-login = { workspace = true } codex-image-generation-extension = { workspace = true } codex-memories-extension = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f3b55337ce..7490460d9c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -212,6 +212,9 @@ Example with notification opt-out: - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. +- `nativeIntegrityState/read` — experimental; read the integrity state file for the initialized client's recognized native surface. Returns `{ state }`, where `state` is `null` when no file exists. +- `nativeIntegrityState/write` — experimental; write the initialized client's recognized native-surface state file. Pass `expectedState` to write only when the file still contains that value; omit it to replace the file unconditionally. Returns `{ written }`. +- `nativeIntegrityState/clear` — experimental; delete the integrity state file for the initialized client's recognized native surface. Returns `{}`. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7aa93ec86e..9e17e179c4 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -29,6 +29,7 @@ use crate::request_processors::GitRequestProcessor; use crate::request_processors::InitializeRequestProcessor; use crate::request_processors::MarketplaceRequestProcessor; use crate::request_processors::McpRequestProcessor; +use crate::request_processors::NativeIntegrityStateRequestProcessor; use crate::request_processors::PluginRequestProcessor; use crate::request_processors::ProcessExecRequestProcessor; use crate::request_processors::RemoteControlRequestProcessor; @@ -176,6 +177,7 @@ pub(crate) struct MessageProcessor { initialize_processor: InitializeRequestProcessor, marketplace_processor: MarketplaceRequestProcessor, mcp_processor: McpRequestProcessor, + native_integrity_state_processor: NativeIntegrityStateRequestProcessor, plugin_processor: PluginRequestProcessor, remote_control_processor: RemoteControlRequestProcessor, search_processor: SearchRequestProcessor, @@ -400,6 +402,8 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), ); + let native_integrity_state_processor = + NativeIntegrityStateRequestProcessor::new(config.codex_home.to_path_buf()); let plugin_processor = PluginRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), @@ -503,6 +507,7 @@ impl MessageProcessor { initialize_processor, marketplace_processor, mcp_processor, + native_integrity_state_processor, plugin_processor, remote_control_processor, search_processor, @@ -922,6 +927,18 @@ impl MessageProcessor { .pairing_start(params) .await .map(|response| Some(response.into())), + ClientRequest::NativeIntegrityStateRead { .. } => self + .native_integrity_state_processor + .read(app_server_client_name.as_deref()) + .map(|response| Some(response.into())), + ClientRequest::NativeIntegrityStateWrite { params, .. } => self + .native_integrity_state_processor + .write(app_server_client_name.as_deref(), params) + .map(|response| Some(response.into())), + ClientRequest::NativeIntegrityStateClear { .. } => self + .native_integrity_state_processor + .clear(app_server_client_name.as_deref()) + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5d8cb4d794..6220635d78 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -466,6 +466,7 @@ mod git_processor; mod initialize_processor; mod marketplace_processor; mod mcp_processor; +mod native_integrity_state_processor; mod plugins; mod process_exec_processor; mod remote_control_processor; @@ -488,6 +489,7 @@ pub(crate) use git_processor::GitRequestProcessor; pub(crate) use initialize_processor::InitializeRequestProcessor; pub(crate) use marketplace_processor::MarketplaceRequestProcessor; pub(crate) use mcp_processor::McpRequestProcessor; +pub(crate) use native_integrity_state_processor::NativeIntegrityStateRequestProcessor; pub(crate) use plugins::PluginRequestProcessor; pub(crate) use process_exec_processor::ProcessExecRequestProcessor; pub(crate) use remote_control_processor::RemoteControlRequestProcessor; diff --git a/codex-rs/app-server/src/request_processors/native_integrity_state_processor.rs b/codex-rs/app-server/src/request_processors/native_integrity_state_processor.rs new file mode 100644 index 0000000000..a5cb2a20ac --- /dev/null +++ b/codex-rs/app-server/src/request_processors/native_integrity_state_processor.rs @@ -0,0 +1,93 @@ +use std::io; +use std::path::PathBuf; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::NativeIntegrityStateClearResponse; +use codex_app_server_protocol::NativeIntegrityStateReadResponse; +use codex_app_server_protocol::NativeIntegrityStateWriteParams; +use codex_app_server_protocol::NativeIntegrityStateWriteResponse; +use codex_client::NativeIntegrityStateStore; +use codex_client::NativeIntegritySurface; + +use crate::error_code::internal_error; +use crate::error_code::invalid_request; + +#[derive(Clone)] +pub(crate) struct NativeIntegrityStateRequestProcessor { + store: NativeIntegrityStateStore, +} + +impl NativeIntegrityStateRequestProcessor { + pub(crate) fn new(codex_home: PathBuf) -> Self { + Self { + store: NativeIntegrityStateStore::new(codex_home), + } + } + + pub(crate) fn read( + &self, + app_server_client_name: Option<&str>, + ) -> Result { + let surface = surface_for_client_name(app_server_client_name)?; + let state = self + .store + .load(surface) + .map_err(map_read_error)? + .map(|state_file| state_file.state); + Ok(NativeIntegrityStateReadResponse { state }) + } + + pub(crate) fn write( + &self, + app_server_client_name: Option<&str>, + params: NativeIntegrityStateWriteParams, + ) -> Result { + let surface = surface_for_client_name(app_server_client_name)?; + let written = match params.expected_state { + Some(expected_state) => { + self.store + .compare_and_store(surface, &expected_state, params.state) + } + None => self.store.replace(surface, params.state).map(|()| true), + } + .map_err(map_write_error)?; + Ok(NativeIntegrityStateWriteResponse { written }) + } + + pub(crate) fn clear( + &self, + app_server_client_name: Option<&str>, + ) -> Result { + let surface = surface_for_client_name(app_server_client_name)?; + self.store.clear(surface).map_err(map_clear_error)?; + Ok(NativeIntegrityStateClearResponse {}) + } +} + +fn surface_for_client_name( + app_server_client_name: Option<&str>, +) -> Result { + let client_name = app_server_client_name + .ok_or_else(|| invalid_request("native integrity state requires an initialized client"))?; + NativeIntegritySurface::try_from_app_server_client_name(client_name).ok_or_else(|| { + invalid_request(format!( + "native integrity state is unavailable for app-server client {client_name:?}" + )) + }) +} + +fn map_read_error(error: io::Error) -> JSONRPCErrorError { + internal_error(format!("failed to read native integrity state: {error}")) +} + +fn map_write_error(error: io::Error) -> JSONRPCErrorError { + if error.kind() == io::ErrorKind::InvalidData { + invalid_request(error.to_string()) + } else { + internal_error(format!("failed to write native integrity state: {error}")) + } +} + +fn map_clear_error(error: io::Error) -> JSONRPCErrorError { + internal_error(format!("failed to clear native integrity state: {error}")) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1605983705..a39e87c01c 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -28,6 +28,7 @@ mod mcp_tool; mod memory_reset; mod model_list; mod model_provider_capabilities_read; +mod native_integrity_state; mod output_schema; mod permission_profile_list; mod plan_item; diff --git a/codex-rs/app-server/tests/suite/v2/native_integrity_state.rs b/codex-rs/app-server/tests/suite/v2/native_integrity_state.rs new file mode 100644 index 0000000000..93936f4cef --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/native_integrity_state.rs @@ -0,0 +1,181 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::NativeIntegrityStateClearResponse; +use codex_app_server_protocol::NativeIntegrityStateReadResponse; +use codex_app_server_protocol::NativeIntegrityStateWriteParams; +use codex_app_server_protocol::NativeIntegrityStateWriteResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const STATE_N: &str = "ois1.a.b.c"; +const STATE_N_PLUS_ONE: &str = "ois1.d.e.f"; + +#[tokio::test] +async fn native_integrity_state_bridge_reads_writes_rotates_and_clears_surface_state() -> Result<()> +{ + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + let initialized = mcp + .initialize_with_client_info(client_info("codex_desktop_ssh")) + .await?; + let JSONRPCMessage::Response(_) = initialized else { + anyhow::bail!("expected initialize response, got {initialized:?}"); + }; + + assert_eq!( + read_state(&mut mcp).await?, + NativeIntegrityStateReadResponse { state: None } + ); + assert_eq!( + write_state( + &mut mcp, + NativeIntegrityStateWriteParams { + state: STATE_N.to_string(), + expected_state: None, + }, + ) + .await?, + NativeIntegrityStateWriteResponse { written: true } + ); + assert_eq!( + read_state(&mut mcp).await?, + NativeIntegrityStateReadResponse { + state: Some(STATE_N.to_string()), + } + ); + assert_eq!( + write_state( + &mut mcp, + NativeIntegrityStateWriteParams { + state: STATE_N_PLUS_ONE.to_string(), + expected_state: Some("stale-state".to_string()), + }, + ) + .await?, + NativeIntegrityStateWriteResponse { written: false } + ); + assert_eq!( + read_state(&mut mcp).await?, + NativeIntegrityStateReadResponse { + state: Some(STATE_N.to_string()), + } + ); + assert_eq!( + write_state( + &mut mcp, + NativeIntegrityStateWriteParams { + state: STATE_N_PLUS_ONE.to_string(), + expected_state: Some(STATE_N.to_string()), + }, + ) + .await?, + NativeIntegrityStateWriteResponse { written: true } + ); + assert_eq!( + read_state(&mut mcp).await?, + NativeIntegrityStateReadResponse { + state: Some(STATE_N_PLUS_ONE.to_string()), + } + ); + + let request_id = mcp + .send_raw_request("nativeIntegrityState/clear", /*params*/ None) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + to_response::(response)?, + NativeIntegrityStateClearResponse {} + ); + assert_eq!( + read_state(&mut mcp).await?, + NativeIntegrityStateReadResponse { state: None } + ); + assert!( + !codex_home + .path() + .join("state/codex_desktop_ssh.json") + .exists() + ); + + Ok(()) +} + +#[tokio::test] +async fn native_integrity_state_bridge_rejects_unknown_client_surface() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + let initialized = mcp + .initialize_with_client_info(client_info("third_party_client")) + .await?; + let JSONRPCMessage::Response(_) = initialized else { + anyhow::bail!("expected initialize response, got {initialized:?}"); + }; + + let request_id = mcp + .send_raw_request("nativeIntegrityState/read", /*params*/ None) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "native integrity state is unavailable for app-server client \"third_party_client\"" + ); + assert_eq!(error.error.data, None); + + Ok(()) +} + +async fn read_state(mcp: &mut TestAppServer) -> Result { + let request_id = mcp + .send_raw_request("nativeIntegrityState/read", /*params*/ None) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn write_state( + mcp: &mut TestAppServer, + params: NativeIntegrityStateWriteParams, +) -> Result { + let request_id = mcp + .send_raw_request( + "nativeIntegrityState/write", + Some(serde_json::to_value(params)?), + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +fn client_info(name: &str) -> ClientInfo { + ClientInfo { + name: name.to_string(), + title: None, + version: "1.0.0".to_string(), + } +}