From 191dc00a71a7902ea81806bfb781aa9998d7e23a Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 29 Apr 2026 15:51:46 -0700 Subject: [PATCH] feat: add remote control enrollment read --- .../src/protocol/common.rs | 22 +++++ .../app-server-protocol/src/protocol/v2.rs | 20 ++++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 5 + codex-rs/app-server/src/in_process.rs | 7 ++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 55 +++++++++++ .../src/message_processor/tracing_tests.rs | 1 + codex-rs/app-server/src/transport/mod.rs | 1 + .../src/transport/remote_control/mod.rs | 2 +- .../src/transport/remote_control/protocol.rs | 8 +- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../v2/remote_control_enrollment_read.rs | 99 +++++++++++++++++++ 13 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/remote_control_enrollment_read.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 94659ee738..dd58e8c872 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -751,6 +751,11 @@ client_request_definitions! { serialization: None, response: v2::ModelProviderCapabilitiesReadResponse, }, + RemoteControlEnrollmentRead => "remoteControl/enrollment/read" { + params: v2::RemoteControlEnrollmentReadParams, + serialization: None, + response: v2::RemoteControlEnrollmentReadResponse, + }, ExperimentalFeatureList => "experimentalFeature/list" { params: v2::ExperimentalFeatureListParams, serialization: global("config"), @@ -2388,6 +2393,23 @@ mod tests { Ok(()) } + #[test] + fn serialize_remote_control_enrollment_read() -> Result<()> { + let request = ClientRequest::RemoteControlEnrollmentRead { + request_id: RequestId::Integer(7), + params: v2::RemoteControlEnrollmentReadParams {}, + }; + assert_eq!( + json!({ + "method": "remoteControl/enrollment/read", + "id": 7, + "params": {} + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_list_collaboration_modes() -> Result<()> { let request = ClientRequest::CollaborationModeList { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4669ec6cc9..ad79e29dae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2946,6 +2946,26 @@ pub struct RemoteControlStatusChangedNotification { pub environment_id: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlEnrollmentReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlEnrollmentReadResponse { + pub enrollment: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlEnrollment { + pub server_id: String, + pub environment_id: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d96b0e514a..4640a34e4a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -206,6 +206,7 @@ Example with notification opt-out: - `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`. - `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. +- `remoteControl/enrollment/read` — read the local remote-control enrollment for the initialized app-server client and current ChatGPT account. Returns `enrollment: null` when no matching enrollment is available; otherwise the enrollment contains only `serverId` and `environmentId`. - `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`; `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. - `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**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a8b3beb871..00e2629dda 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1310,6 +1310,11 @@ impl CodexMessageProcessor { "ModelProviderCapabilitiesRead request reached CodexMessageProcessor unexpectedly" ); } + ClientRequest::RemoteControlEnrollmentRead { .. } => { + warn!( + "RemoteControlEnrollmentRead request reached CodexMessageProcessor unexpectedly" + ); + } ClientRequest::ExternalAgentConfigDetect { .. } | ClientRequest::ExternalAgentConfigImport { .. } => { warn!("ExternalAgentConfig request reached CodexMessageProcessor unexpectedly"); diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 0f7a31d6cb..6667c1f79d 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -399,6 +399,12 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { }); let processor_outgoing = Arc::clone(&outgoing_message_sender); + let state_db = codex_state::StateRuntime::init( + args.config.sqlite_home.clone(), + args.config.model_provider_id.clone(), + ) + .await + .ok(); let config_manager = ConfigManager::new( args.config.codex_home.to_path_buf(), args.cli_overrides, @@ -418,6 +424,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { environment_manager: args.environment_manager, feedback: args.feedback, log_db: args.log_db, + state_db, config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4df869551e..91e8a8762d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -749,6 +749,7 @@ pub async fn run_main_with_transport_options( environment_manager, feedback: feedback.clone(), log_db, + state_db, config_warnings, session_source, auth_manager, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 1a28f8e278..1fd5f92ea7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -11,6 +11,7 @@ use crate::config_api::ConfigApi; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::device_key_api::DeviceKeyApi; +use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; @@ -25,6 +26,7 @@ use crate::request_serialization::RequestSerializationQueues; use crate::transport::AppServerTransport; use crate::transport::ConnectionOrigin; use crate::transport::RemoteControlHandle; +use crate::transport::normalize_remote_control_url; use async_trait::async_trait; use axum::http::HeaderValue; use codex_analytics::AnalyticsEventsClient; @@ -58,6 +60,8 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; +use codex_app_server_protocol::RemoteControlEnrollment; +use codex_app_server_protocol::RemoteControlEnrollmentReadResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; @@ -83,6 +87,7 @@ use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; +use codex_state::StateRuntime; use codex_state::log_db::LogDbLayer; use futures::FutureExt; use tokio::sync::broadcast; @@ -169,6 +174,7 @@ pub(crate) struct MessageProcessor { external_agent_config_api: ExternalAgentConfigApi, fs_api: FsApi, auth_manager: Arc, + state_db: Option>, analytics_events_client: AnalyticsEventsClient, fs_watch_manager: FsWatchManager, config: Arc, @@ -255,6 +261,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) environment_manager: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, + pub(crate) state_db: Option>, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, @@ -276,6 +283,7 @@ impl MessageProcessor { environment_manager, feedback, log_db, + state_db, config_warnings, session_source, auth_manager, @@ -351,6 +359,7 @@ impl MessageProcessor { external_agent_config_api, fs_api, auth_manager, + state_db, analytics_events_client, fs_watch_manager, config, @@ -946,6 +955,17 @@ impl MessageProcessor { self.handle_model_provider_capabilities_read(request_id_for_connection(request_id)) .await; } + ClientRequest::RemoteControlEnrollmentRead { + request_id, + params: _, + } => { + let result = self + .remote_control_enrollment_read(app_server_client_name.as_deref()) + .await; + self.outgoing + .send_result(request_id_for_connection(request_id), result) + .await; + } other => { // Box the delegated future so this wrapper's async state machine does not // inline the full `CodexMessageProcessor::process_request` future, which @@ -983,6 +1003,41 @@ impl MessageProcessor { self.outgoing.send_result(request_id, result).await; } + async fn remote_control_enrollment_read( + &self, + app_server_client_name: Option<&str>, + ) -> Result { + let Some(state_db) = self.state_db.as_deref() else { + return Ok(RemoteControlEnrollmentReadResponse { enrollment: None }); + }; + let Some(auth) = self.auth_manager.auth().await else { + return Ok(RemoteControlEnrollmentReadResponse { enrollment: None }); + }; + let Some(account_id) = auth.get_account_id() else { + return Ok(RemoteControlEnrollmentReadResponse { enrollment: None }); + }; + let remote_control_target = normalize_remote_control_url(&self.config.chatgpt_base_url) + .map_err(|err| { + internal_error(format!("failed to resolve remote control URL: {err}")) + })?; + let enrollment = state_db + .get_remote_control_enrollment( + &remote_control_target.websocket_url, + &account_id, + app_server_client_name, + ) + .await + .map_err(|err| { + internal_error(format!("failed to read remote control enrollment: {err}")) + })? + .map(|enrollment| RemoteControlEnrollment { + server_id: enrollment.server_id, + environment_id: enrollment.environment_id, + }); + + Ok(RemoteControlEnrollmentReadResponse { enrollment }) + } + async fn handle_config_value_write( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index bb247be59e..9b86fa4166 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -290,6 +290,7 @@ async fn build_test_processor( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), feedback: CodexFeedback::new(), log_db: None, + state_db: None, config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index b610f099ae..a55b72c14c 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -41,6 +41,7 @@ mod unix_socket_tests; mod websocket; pub(crate) use remote_control::RemoteControlHandle; +pub(crate) use remote_control::normalize_remote_control_url; pub(crate) use remote_control::start_remote_control; pub(crate) use stdio::start_stdio_connection; pub(crate) use unix_socket::start_control_socket_acceptor; diff --git a/codex-rs/app-server/src/transport/remote_control/mod.rs b/codex-rs/app-server/src/transport/remote_control/mod.rs index ef517e1ae2..30709bc097 100644 --- a/codex-rs/app-server/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server/src/transport/remote_control/mod.rs @@ -10,7 +10,7 @@ use crate::transport::remote_control::websocket::RemoteControlWebsocket; pub use self::protocol::ClientId; use self::protocol::ServerEvent; use self::protocol::StreamId; -use self::protocol::normalize_remote_control_url; +pub(crate) use self::protocol::normalize_remote_control_url; use super::CHANNEL_CAPACITY; use super::TransportEvent; use super::next_connection_id; diff --git a/codex-rs/app-server/src/transport/remote_control/protocol.rs b/codex-rs/app-server/src/transport/remote_control/protocol.rs index f0db5ecacb..0e9d414774 100644 --- a/codex-rs/app-server/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server/src/transport/remote_control/protocol.rs @@ -8,9 +8,9 @@ use url::Host; use url::Url; #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct RemoteControlTarget { - pub(super) websocket_url: String, - pub(super) enroll_url: String, +pub(crate) struct RemoteControlTarget { + pub(crate) websocket_url: String, + pub(crate) enroll_url: String, } #[derive(Debug, Serialize)] @@ -124,7 +124,7 @@ fn is_localhost(host: &Option>) -> bool { } } -pub(super) fn normalize_remote_control_url( +pub(crate) fn normalize_remote_control_url( remote_control_url: &str, ) -> io::Result { let map_url_parse_error = |err: url::ParseError| -> io::Error { diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index db1cc71d7e..44438ee2cd 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -35,6 +35,7 @@ mod plugin_read; mod plugin_uninstall; mod rate_limits; mod realtime_conversation; +mod remote_control_enrollment_read; #[cfg(debug_assertions)] mod remote_thread_store; mod request_permissions; diff --git a/codex-rs/app-server/tests/suite/v2/remote_control_enrollment_read.rs b/codex-rs/app-server/tests/suite/v2/remote_control_enrollment_read.rs new file mode 100644 index 0000000000..eeac288659 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/remote_control_enrollment_read.rs @@ -0,0 +1,99 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::RemoteControlEnrollment; +use codex_app_server_protocol::RemoteControlEnrollmentReadResponse; +use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use codex_state::RemoteControlEnrollmentRecord; +use codex_state::StateRuntime; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +const CHATGPT_BASE_URL: &str = "https://chatgpt.com/backend-api"; +const REMOTE_CONTROL_WEBSOCKET_URL: &str = + "wss://chatgpt.com/backend-api/wham/remote/control/server"; + +#[tokio::test] +async fn reads_persisted_remote_control_enrollment_for_initialized_client() -> Result<()> { + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + "http://localhost:0", + CHATGPT_BASE_URL, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-token") + .account_id("account-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + state_db + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: REMOTE_CONTROL_WEBSOCKET_URL.to_string(), + account_id: "account-123".to_string(), + app_server_client_name: Some(DEFAULT_CLIENT_NAME.to_string()), + server_id: "srv_e_test".to_string(), + environment_id: "env_test".to_string(), + server_name: "test-server".to_string(), + }) + .await?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + assert_eq!( + read_remote_control_enrollment(&mut mcp).await?, + RemoteControlEnrollmentReadResponse { + enrollment: Some(RemoteControlEnrollment { + server_id: "srv_e_test".to_string(), + environment_id: "env_test".to_string(), + }), + } + ); + Ok(()) +} + +#[tokio::test] +async fn returns_null_when_remote_control_enrollment_is_unknown() -> Result<()> { + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + "http://localhost:0", + CHATGPT_BASE_URL, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + assert_eq!( + read_remote_control_enrollment(&mut mcp).await?, + RemoteControlEnrollmentReadResponse { enrollment: None } + ); + Ok(()) +} + +async fn read_remote_control_enrollment( + mcp: &mut McpProcess, +) -> Result { + let request_id = mcp + .send_raw_request("remoteControl/enrollment/read", Some(serde_json::json!({}))) + .await?; + let response = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +}