From 4e368aa2e93a1bac78419a615d075ffb20502b24 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 13 May 2026 18:07:46 -0700 Subject: [PATCH] enable/disable remote control at runtime, not via features (#22578) ## Why reapplies https://github.com/openai/codex/pull/22386 which was previously reverted Also, introduce `remoteControl/enable` and `remoteControl/disable` app-server APIs to toggle on/off remote control at runtime for a given running app-server instance. ## What Changed - Adds experimental v2 RPCs: - `remoteControl/enable` - `remoteControl/disable` - Adds `RemoteControlRequestProcessor` and routes the new RPCs through it instead of `ConfigRequestProcessor`. - Adds named `RemoteControlHandle::enable`, `disable`, and `status` methods. - Makes `remoteControl/enable` return an error when sqlite state DB is unavailable, while keeping enrollment/websocket failures as async status updates. - Adds `AppServerRuntimeOptions.remote_control_enabled` and hidden `--remote-control` flags for `codex app-server` and `codex-app-server`. - Updates managed daemon startup to use `codex app-server --remote-control --listen unix://`. - Marks `Feature::RemoteControl` as removed and ignores `[features].remote_control`. - Updates app-server README entries for the new remote-control methods. --- codex-rs/app-server-daemon/src/backend/pid.rs | 8 +- .../src/backend/pid_tests.rs | 14 ++++ .../src/protocol/common.rs | 12 +++ .../src/protocol/v2/remote_control.rs | 48 ++++++++++++ codex-rs/app-server-transport/src/lib.rs | 1 + .../app-server-transport/src/transport/mod.rs | 1 + .../src/transport/remote_control/mod.rs | 78 +++++++++++++++++-- .../src/transport/remote_control/tests.rs | 27 +++++-- codex-rs/app-server/README.md | 4 +- codex-rs/app-server/src/lib.rs | 11 +-- codex-rs/app-server/src/main.rs | 6 ++ codex-rs/app-server/src/message_processor.rs | 13 +++- codex-rs/app-server/src/request_processors.rs | 2 + .../request_processors/config_processor.rs | 20 ----- .../remote_control_processor.rs | 43 ++++++++++ codex-rs/app-server/src/transport.rs | 1 + .../app-server/tests/common/mcp_process.rs | 12 +++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/remote_control.rs | 54 +++++++++++++ codex-rs/cli/src/main.rs | 18 ++++- codex-rs/features/src/lib.rs | 5 +- codex-rs/features/src/tests.rs | 19 ++++- 22 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/remote_control_processor.rs create mode 100644 codex-rs/app-server/tests/suite/v2/remote_control.rs diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 64228d9c9b..7f8aa1660d 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -349,13 +349,7 @@ impl PidBackend { match self.command_kind { PidCommandKind::AppServer { remote_control_enabled: true, - } => vec![ - "--enable", - "remote_control", - "app-server", - "--listen", - "unix://", - ], + } => vec!["app-server", "--remote-control", "--listen", "unix://"], PidCommandKind::AppServer { remote_control_enabled: false, } => vec!["app-server", "--listen", "unix://"], diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index dc279794c2..305c2a36a3 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -156,3 +156,17 @@ fn update_loop_uses_hidden_app_server_subcommand() { vec!["app-server", "daemon", "pid-update-loop"] ); } + +#[test] +fn app_server_remote_control_uses_runtime_flag() { + let backend = PidBackend::new( + "codex".into(), + "app-server.pid".into(), + /*remote_control_enabled*/ true, + ); + + assert_eq!( + backend.command_args(), + vec!["app-server", "--remote-control", "--listen", "unix://"] + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b70af1a22b..e63b435344 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -799,6 +799,18 @@ client_request_definitions! { serialization: global("config"), response: v2::ExperimentalFeatureEnablementSetResponse, }, + #[experimental("remoteControl/enable")] + RemoteControlEnable => "remoteControl/enable" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("remote-control"), + response: v2::RemoteControlEnableResponse, + }, + #[experimental("remoteControl/disable")] + RemoteControlDisable => "remoteControl/disable" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("remote-control"), + response: v2::RemoteControlDisableResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index 7dab24c3d4..e89a9d19b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -13,6 +13,24 @@ pub struct RemoteControlStatusChangedNotification { pub environment_id: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlEnableResponse { + pub status: RemoteControlConnectionStatus, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlDisableResponse { + pub status: RemoteControlConnectionStatus, + pub installation_id: String, + pub environment_id: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -22,3 +40,33 @@ pub enum RemoteControlConnectionStatus { Connected, Errored, } + +impl From for RemoteControlEnableResponse { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + installation_id, + environment_id, + } = notification; + Self { + status, + installation_id, + environment_id, + } + } +} + +impl From for RemoteControlDisableResponse { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + installation_id, + environment_id, + } = notification; + Self { + status, + installation_id, + environment_id, + } + } +} diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index e3f39f60ce..f4eb1fc6a5 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -12,6 +12,7 @@ pub use transport::CHANNEL_CAPACITY; pub use transport::ConnectionOrigin; pub use transport::RemoteControlHandle; pub use transport::RemoteControlStartConfig; +pub use transport::RemoteControlUnavailable; pub use transport::TransportEvent; pub use transport::app_server_control_socket_path; pub use transport::auth; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index 3e76069f43..a9e806c128 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -32,6 +32,7 @@ mod websocket; pub use remote_control::RemoteControlHandle; pub use remote_control::RemoteControlStartConfig; +pub use remote_control::RemoteControlUnavailable; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; pub use unix_socket::start_control_socket_acceptor; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 9ffe8b60bb..8722f4e9ee 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -19,6 +19,8 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use std::error::Error; +use std::fmt; use std::io; use std::sync::Arc; use tokio::sync::mpsc; @@ -47,23 +49,87 @@ pub struct RemoteControlHandle { state_db_available: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteControlUnavailable; + +impl fmt::Display for RemoteControlUnavailable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "remote control cannot be enabled because sqlite state db is unavailable" + ) + } +} + +impl Error for RemoteControlUnavailable {} + impl RemoteControlHandle { - pub fn set_enabled(&self, enabled: bool) { - let requested_enabled = enabled; - let enabled = enabled && self.state_db_available; - if requested_enabled && !self.state_db_available { + pub fn enable( + &self, + ) -> Result { + if !self.state_db_available { warn!("remote control cannot be enabled because sqlite state db is unavailable"); + return Err(RemoteControlUnavailable); } + self.enabled_tx.send_if_modified(|state| { - let changed = *state != enabled; - *state = enabled; + let changed = !*state; + *state = true; changed }); + + let status = self.status(); + if matches!( + status.status, + RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting + ) { + return Ok(status); + } + + Ok(self.publish_status(RemoteControlConnectionStatus::Connecting)) + } + + pub fn disable(&self) -> RemoteControlStatusChangedNotification { + self.enabled_tx.send_if_modified(|state| { + let changed = *state; + *state = false; + changed + }); + + self.publish_status(RemoteControlConnectionStatus::Disabled) + } + + pub fn status(&self) -> RemoteControlStatusChangedNotification { + self.status_tx.borrow().clone() } pub fn status_receiver(&self) -> watch::Receiver { self.status_tx.subscribe() } + + fn publish_status( + &self, + connection_status: RemoteControlConnectionStatus, + ) -> RemoteControlStatusChangedNotification { + self.status_tx.send_if_modified(|status| { + let next_status = RemoteControlStatusChangedNotification { + status: connection_status, + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + }; + if *status == next_status { + return false; + } + + *status = next_status; + true + }); + self.status() + } } pub async fn start_remote_control( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 88b7798827..3dcfb81ce5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -639,7 +639,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( .await .expect_err("remote control should not connect without sqlite state db"); - remote_handle.set_enabled(/*enabled*/ true); + assert_eq!( + remote_handle.enable().expect_err("enable should fail"), + super::RemoteControlUnavailable + ); timeout(Duration::from_millis(100), listener.accept()) .await .expect_err("remote control should remain disabled without sqlite state db"); @@ -655,7 +658,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( } #[tokio::test] -async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { +async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -701,7 +704,14 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { ) .await; - remote_handle.set_enabled(/*enabled*/ false); + assert_eq!( + remote_handle.disable(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + } + ); expect_remote_control_status_snapshot( &mut status_rx, RemoteControlStatusChangedNotification { @@ -718,13 +728,20 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { .await .expect_err("disabled remote control should not reconnect"); - remote_handle.set_enabled(/*enabled*/ true); + assert_eq!( + remote_handle.enable().expect("enable should succeed"), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + } + ); expect_remote_control_status_snapshot( &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, installation_id: TEST_INSTALLATION_ID.to_string(), - environment_id: Some("env_test".to_string()), + environment_id: None, }, ) .await; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 788fc9e7c3..19d4fef01a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,7 +189,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). @@ -202,6 +202,8 @@ Example with notification opt-out: - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. +- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. +- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `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/lib.rs b/codex-rs/app-server/src/lib.rs index 9178cc01cb..350acf002f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,7 +8,6 @@ use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::resolve_installation_id; -use codex_features::Feature; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; @@ -401,12 +400,14 @@ pub enum PluginStartupTasks { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, + pub remote_control_enabled: bool, } impl Default for AppServerRuntimeOptions { fn default() -> Self { Self { plugin_startup_tasks: PluginStartupTasks::Start, + remote_control_enabled: false, } } } @@ -684,15 +685,15 @@ pub async fn run_main_with_transport_options( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; - let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); - let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); - if remote_control_config_enabled && state_db.is_none() { + let remote_control_requested = runtime_options.remote_control_enabled; + let remote_control_enabled = remote_control_requested && state_db.is_some(); + if remote_control_requested && state_db.is_none() { error!("remote control disabled because sqlite state db is unavailable"); } if transport_accept_handles.is_empty() && !remote_control_enabled { return Err(std::io::Error::new( ErrorKind::InvalidInput, - if remote_control_config_enabled && state_db.is_none() { + if remote_control_requested && state_db.is_none() { "no transport configured; remote control disabled because sqlite state db is unavailable" } else { "no transport configured; use --listen or enable remote control" diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index af30db8cf0..6f5ecabf90 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -48,6 +48,10 @@ struct AppServerArgs { #[cfg(debug_assertions)] #[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)] disable_plugin_startup_tasks_for_tests: bool, + + /// Enable remote control for this app-server process. + #[arg(long = "remote-control", hide = true)] + remote_control: bool, } fn main() -> anyhow::Result<()> { @@ -59,6 +63,7 @@ fn main() -> anyhow::Result<()> { strict_config, #[cfg(debug_assertions)] disable_plugin_startup_tasks_for_tests, + remote_control, } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() @@ -74,6 +79,7 @@ fn main() -> anyhow::Result<()> { if disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } + runtime_options.remote_control_enabled = remote_control; run_main_with_transport_options( arg0_paths, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a4eb9b6c9d..0f291abfcc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -30,6 +30,7 @@ use crate::request_processors::MarketplaceRequestProcessor; use crate::request_processors::McpRequestProcessor; use crate::request_processors::PluginRequestProcessor; use crate::request_processors::ProcessExecRequestProcessor; +use crate::request_processors::RemoteControlRequestProcessor; use crate::request_processors::SearchRequestProcessor; use crate::request_processors::ThreadGoalRequestProcessor; use crate::request_processors::ThreadRequestProcessor; @@ -173,6 +174,7 @@ pub(crate) struct MessageProcessor { marketplace_processor: MarketplaceRequestProcessor, mcp_processor: McpRequestProcessor, plugin_processor: PluginRequestProcessor, + remote_control_processor: RemoteControlRequestProcessor, search_processor: SearchRequestProcessor, thread_goal_processor: ThreadGoalRequestProcessor, thread_processor: ThreadRequestProcessor, @@ -389,6 +391,7 @@ impl MessageProcessor { config_manager.clone(), workspace_settings_cache, ); + let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle); let search_processor = SearchRequestProcessor::new(outgoing.clone()); let thread_goal_processor = ThreadGoalRequestProcessor::new( Arc::clone(&thread_manager), @@ -446,7 +449,6 @@ impl MessageProcessor { auth_manager, thread_manager.clone(), analytics_events_client, - remote_control_handle, ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( outgoing.clone(), @@ -488,6 +490,7 @@ impl MessageProcessor { marketplace_processor, mcp_processor, plugin_processor, + remote_control_processor, search_processor, thread_goal_processor, thread_processor, @@ -886,6 +889,14 @@ impl MessageProcessor { .experimental_feature_enablement_set(request_id.clone(), params) .await } + ClientRequest::RemoteControlEnable { .. } => self + .remote_control_processor + .enable() + .map(|response| Some(response.into())), + ClientRequest::RemoteControlDisable { .. } => self + .remote_control_processor + .disable() + .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 d2b681d063..2c20c1816a 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -448,6 +448,7 @@ mod marketplace_processor; mod mcp_processor; mod plugins; mod process_exec_processor; +mod remote_control_processor; mod search; mod thread_processor; mod token_usage_replay; @@ -469,6 +470,7 @@ pub(crate) use marketplace_processor::MarketplaceRequestProcessor; pub(crate) use mcp_processor::McpRequestProcessor; pub(crate) use plugins::PluginRequestProcessor; pub(crate) use process_exec_processor::ProcessExecRequestProcessor; +pub(crate) use remote_control_processor::RemoteControlRequestProcessor; pub(crate) use search::SearchRequestProcessor; pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; pub(crate) use thread_processor::ThreadRequestProcessor; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 1533728e9f..b7f973b076 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -6,7 +6,6 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; -use crate::transport::RemoteControlHandle; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; @@ -39,7 +38,6 @@ use codex_config::MatcherGroup as CoreMatcherGroup; use codex_config::ResidencyRequirement as CoreResidencyRequirement; use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; use codex_core::ThreadManager; -use codex_features::Feature; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; use codex_login::AuthManager; @@ -67,7 +65,6 @@ pub(crate) struct ConfigRequestProcessor { auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, - remote_control_handle: Option, } impl ConfigRequestProcessor { @@ -77,7 +74,6 @@ impl ConfigRequestProcessor { auth_manager: Arc, thread_manager: Arc, analytics_events_client: AnalyticsEventsClient, - remote_control_handle: Option, ) -> Self { Self { outgoing, @@ -85,7 +81,6 @@ impl ConfigRequestProcessor { auth_manager, thread_manager, analytics_events_client, - remote_control_handle, } } @@ -187,21 +182,6 @@ impl ConfigRequestProcessor { pub(crate) async fn handle_config_mutation(&self) { self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); - let Some(remote_control_handle) = &self.remote_control_handle else { - return; - }; - - match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => { - remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl)); - } - Err(error) => { - tracing::warn!( - "failed to load config for remote control enablement refresh after config mutation: {}", - error.message - ); - } - } } async fn handle_config_mutation_result( diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs new file mode 100644 index 0000000000..41a124e94b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -0,0 +1,43 @@ +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::transport::RemoteControlHandle; +use crate::transport::RemoteControlUnavailable; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RemoteControlDisableResponse; +use codex_app_server_protocol::RemoteControlEnableResponse; + +#[derive(Clone)] +pub(crate) struct RemoteControlRequestProcessor { + remote_control_handle: Option, +} + +impl RemoteControlRequestProcessor { + pub(crate) fn new(remote_control_handle: Option) -> Self { + Self { + remote_control_handle, + } + } + + pub(crate) fn enable(&self) -> Result { + let handle = self.handle()?; + handle + .enable() + .map(RemoteControlEnableResponse::from) + .map_err(map_unavailable) + } + + pub(crate) fn disable(&self) -> Result { + let handle = self.handle()?; + Ok(RemoteControlDisableResponse::from(handle.disable())) + } + + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { + self.remote_control_handle + .as_ref() + .ok_or_else(|| internal_error("remote control is unavailable for this app-server")) + } +} + +fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError { + invalid_request(err.to_string()) +} diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 4eae17e469..f7222bd28f 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -20,6 +20,7 @@ pub(crate) use codex_app_server_transport::OutgoingMessage; pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; +pub(crate) use codex_app_server_transport::RemoteControlUnavailable; pub(crate) use codex_app_server_transport::TransportEvent; pub use codex_app_server_transport::app_server_control_socket_path; pub use codex_app_server_transport::auth; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 81a5b2b401..19f3b5b85e 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -570,6 +570,18 @@ impl McpProcess { .await } + /// Send a `remoteControl/enable` JSON-RPC request. + pub async fn send_remote_control_enable_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/enable", /*params*/ None) + .await + } + + /// Send a `remoteControl/disable` JSON-RPC request. + pub async fn send_remote_control_disable_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/disable", /*params*/ None) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 642be8ad4a..bdbe7b7ddd 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -38,6 +38,7 @@ mod plugin_uninstall; mod process_exec; mod rate_limits; mod realtime_conversation; +mod remote_control; #[cfg(debug_assertions)] mod remote_thread_store; mod request_permissions; diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs new file mode 100644 index 0000000000..ae6bc9013f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -0,0 +1,54 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlDisableResponse; +use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RequestId; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn remote_control_disable_returns_disabled_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_disable_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlDisableResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_enable_returns_connecting_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlEnableResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b18120c078..99951d12ea 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -454,6 +454,10 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Enable remote control for this app-server process. + #[arg(long = "remote-control", hide = true)] + remote_control: bool, + /// Controls whether analytics are enabled by default. /// /// Analytics are disabled by default for app-server. Users have to explicitly opt in @@ -532,10 +536,10 @@ enum AppServerDaemonSubcommand { /// Restart the local app server daemon. Restart, - /// Enable remote_control for future starts and a currently running managed daemon. + /// Enable remote control for future starts and a currently running managed daemon. EnableRemoteControl, - /// Disable remote_control for future starts and a currently running managed daemon. + /// Disable remote control for future starts and a currently running managed daemon. DisableRemoteControl, /// Stop the local app server daemon. @@ -558,7 +562,7 @@ struct AppServerProxyCommand { #[derive(Debug, Args)] struct AppServerBootstrapCommand { - /// Launch the managed app-server with remote_control enabled. + /// Launch the managed app-server with remote control enabled. #[arg(long = "remote-control")] remote_control: bool, } @@ -945,6 +949,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, strict_config: app_server_strict_config, listen, + remote_control, analytics_default_enabled, auth, } = app_server_cli; @@ -959,6 +964,10 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { None => { let transport = listen; let auth = auth.try_into_settings()?; + let runtime_options = codex_app_server::AppServerRuntimeOptions { + remote_control_enabled: remote_control, + ..Default::default() + }; codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, @@ -968,7 +977,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { transport, codex_protocol::protocol::SessionSource::VSCode, auth, - codex_app_server::AppServerRuntimeOptions::default(), + runtime_options, ) .await?; } @@ -2607,6 +2616,7 @@ mod tests { fn app_server_analytics_default_disabled_without_flag() { let app_server = app_server_from_args(["codex", "app-server"].as_ref()); assert!(!app_server.analytics_default_enabled); + assert!(!app_server.remote_control); assert_eq!( app_server.listen, codex_app_server::AppServerTransport::Stdio diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6756ce7af0..da74d21e94 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -421,6 +421,9 @@ impl Features { "js_repl_tools_only" => { continue; } + "remote_control" => { + continue; + } "image_detail_original" => { continue; } @@ -1117,7 +1120,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RemoteControl, key: "remote_control", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 567baed2c2..21a6ecf5ba 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -293,9 +293,24 @@ fn auth_elicitation_is_under_development() { } #[test] -fn remote_control_is_under_development() { - assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); +fn remote_control_is_removed_and_disabled_by_default() { + assert_eq!(Feature::RemoteControl.stage(), Stage::Removed); assert_eq!(Feature::RemoteControl.default_enabled(), false); + assert_eq!( + feature_for_key("remote_control"), + Some(Feature::RemoteControl) + ); +} + +#[test] +fn remote_control_config_is_ignored() { + let mut entries = BTreeMap::new(); + entries.insert("remote_control".to_string(), true); + + let mut features = Features::with_defaults(); + features.apply_map(&entries); + + assert_eq!(features.enabled(Feature::RemoteControl), false); } #[test]