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.
This commit is contained in:
Owen Lin
2026-05-14 01:07:46 +00:00
committed by GitHub
parent 512f8f8012
commit 4e368aa2e9
22 changed files with 346 additions and 52 deletions
+3 -1
View File
@@ -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 <feature_name> > 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 <feature_name> > 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**).
+6 -5
View File
@@ -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"
+6
View File
@@ -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,
+12 -1
View File
@@ -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()
@@ -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;
@@ -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<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
}
impl ConfigRequestProcessor {
@@ -77,7 +74,6 @@ impl ConfigRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
) -> 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<T>(
@@ -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<RemoteControlHandle>,
}
impl RemoteControlRequestProcessor {
pub(crate) fn new(remote_control_handle: Option<RemoteControlHandle>) -> Self {
Self {
remote_control_handle,
}
}
pub(crate) fn enable(&self) -> Result<RemoteControlEnableResponse, JSONRPCErrorError> {
let handle = self.handle()?;
handle
.enable()
.map(RemoteControlEnableResponse::from)
.map_err(map_unavailable)
}
pub(crate) fn disable(&self) -> Result<RemoteControlDisableResponse, JSONRPCErrorError> {
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())
}
+1
View File
@@ -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;
@@ -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<i64> {
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<i64> {
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<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -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;
@@ -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(())
}