diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e2c131446..b5fe28b8a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8139,6 +8139,12 @@ "null" ] }, + "allowRemoteControl": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/v2/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fc91ed3f7..65d18213d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4452,6 +4452,12 @@ "null" ] }, + "allowRemoteControl": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index def89d64e..e57bd48b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -85,6 +85,12 @@ "null" ] }, + "allowRemoteControl": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 29704982f..3eb9a65aa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 31568f96d..d8d4a3af2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -381,6 +381,7 @@ pub struct ConfigRequirements { pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, + pub allow_remote_control: Option, pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c919d957b..569ae666c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1704,6 +1704,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index d0c4d8faa..9fb49f591 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -12,7 +12,10 @@ pub use transport::AppServerTransportParseError; pub use transport::CHANNEL_CAPACITY; pub use transport::ConnectionOrigin; pub use transport::REMOTE_CONTROL_DISABLED_ENV_VAR; +pub use transport::RemoteControlDisabledByRequirements; +pub use transport::RemoteControlEnableError; pub use transport::RemoteControlHandle; +pub use transport::RemoteControlPolicy; pub use transport::RemoteControlStartConfig; pub use transport::RemoteControlStartupMode; pub use transport::RemoteControlUnavailable; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index b8f867a90..040de5c44 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -31,7 +31,10 @@ mod unix_socket_tests; mod websocket; pub use remote_control::REMOTE_CONTROL_DISABLED_ENV_VAR; +pub use remote_control::RemoteControlDisabledByRequirements; +pub use remote_control::RemoteControlEnableError; pub use remote_control::RemoteControlHandle; +pub use remote_control::RemoteControlPolicy; pub use remote_control::RemoteControlStartConfig; pub use remote_control::RemoteControlStartupMode; pub use remote_control::RemoteControlUnavailable; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/desired_state.rs b/codex-rs/app-server-transport/src/transport/remote_control/desired_state.rs index 5d4896247..faca908f5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/desired_state.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/desired_state.rs @@ -1,3 +1,4 @@ +use super::RemoteControlEnableError; use super::RemoteControlHandle; use super::RemoteControlUnavailable; use super::enroll::update_persisted_remote_control_enrollment; @@ -51,6 +52,9 @@ impl RemoteControlHandle { &self, app_server_client_name: Option<&str>, ) -> io::Result { + if self.ensure_remote_control_allowed().is_err() { + return Ok(false); + } let _transition = self .desired_state_rpc_lock .acquire() @@ -93,6 +97,8 @@ impl RemoteControlHandle { &self, app_server_client_name: Option<&str>, ) -> io::Result { + self.ensure_remote_control_allowed() + .map_err(|err| io::Error::new(io::ErrorKind::PermissionDenied, err))?; let _transition = self .desired_state_rpc_lock .acquire() @@ -149,8 +155,15 @@ impl RemoteControlHandle { .await?; } publish_current_enrollment(&mut current_enrollment, &enrollment); - self.enable_with_preference(Some(true)) - .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; + self.enable_with_preference(Some(true)).map_err(|err| { + let kind = match err { + RemoteControlEnableError::Unavailable(_) => io::ErrorKind::NotFound, + RemoteControlEnableError::DisabledByRequirements(_) => { + io::ErrorKind::PermissionDenied + } + }; + io::Error::new(kind, err) + })?; RemoteControlStatusPublisher::new(self.status_tx.as_ref().clone()) .publish_environment_id(Some(enrollment.environment_id)); Ok(self.status()) 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 d7d147c0d..d0e934d90 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 @@ -64,6 +64,14 @@ use tracing::warn; pub struct RemoteControlStartConfig { pub remote_control_url: String, pub installation_id: String, + pub policy: RemoteControlPolicy, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum RemoteControlPolicy { + #[default] + Allowed, + DisabledByRequirements, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -95,6 +103,7 @@ pub(super) struct QueuedServerEnvelope { #[derive(Clone)] pub struct RemoteControlHandle { + policy: RemoteControlPolicy, desired_state_tx: Arc>, desired_state_rpc_lock: Arc, desired_state_persistence_lock: Arc, @@ -195,20 +204,64 @@ impl fmt::Display for RemoteControlUnavailable { impl Error for RemoteControlUnavailable {} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteControlDisabledByRequirements; + +impl fmt::Display for RemoteControlDisabledByRequirements { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "remote control is disabled by managed requirements") + } +} + +impl Error for RemoteControlDisabledByRequirements {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteControlEnableError { + Unavailable(RemoteControlUnavailable), + DisabledByRequirements(RemoteControlDisabledByRequirements), +} + +impl fmt::Display for RemoteControlEnableError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable(err) => err.fmt(f), + Self::DisabledByRequirements(err) => err.fmt(f), + } + } +} + +impl Error for RemoteControlEnableError {} + impl RemoteControlHandle { + pub fn ensure_remote_control_allowed(&self) -> Result<(), RemoteControlDisabledByRequirements> { + match self.policy { + RemoteControlPolicy::Allowed => Ok(()), + RemoteControlPolicy::DisabledByRequirements => Err(RemoteControlDisabledByRequirements), + } + } + + fn ensure_remote_control_allowed_io(&self) -> io::Result<()> { + self.ensure_remote_control_allowed() + .map_err(|err| io::Error::new(io::ErrorKind::PermissionDenied, err)) + } + pub fn enable_ephemeral( &self, - ) -> Result { + ) -> Result { self.enable_with_preference(/*persistence_preference*/ None) } fn enable_with_preference( &self, persistence_preference: Option, - ) -> Result { + ) -> Result { + self.ensure_remote_control_allowed() + .map_err(RemoteControlEnableError::DisabledByRequirements)?; if self.state_db.is_none() { warn!("remote control cannot be enabled because sqlite state db is unavailable"); - return Err(RemoteControlUnavailable); + return Err(RemoteControlEnableError::Unavailable( + RemoteControlUnavailable, + )); } let mut effective_persistence_preference = persistence_preference; @@ -255,6 +308,7 @@ impl RemoteControlHandle { &self, app_server_client_name: Option<&str>, ) -> io::Result { + self.ensure_remote_control_allowed_io()?; let _transition = self .desired_state_rpc_lock .acquire() @@ -334,6 +388,7 @@ impl RemoteControlHandle { params: RemoteControlPairingStartParams, app_server_client_name: Option<&str>, ) -> io::Result { + self.ensure_remote_control_allowed_io()?; if !self.desired_state_tx.borrow().is_enabled() { return Err(Self::pairing_disabled_error()); } @@ -564,6 +619,7 @@ impl RemoteControlHandle { &self, params: RemoteControlPairingStatusParams, ) -> io::Result { + self.ensure_remote_control_allowed_io()?; if !self.desired_state_tx.borrow().is_enabled() { return Err(Self::pairing_disabled_error()); } @@ -663,6 +719,7 @@ impl RemoteControlHandle { &self, params: RemoteControlClientsListParams, ) -> io::Result { + self.ensure_remote_control_allowed_io()?; clients::list_remote_control_clients(&self.remote_control_url, &self.auth_manager, params) .await } @@ -671,6 +728,7 @@ impl RemoteControlHandle { &self, params: RemoteControlClientsRevokeParams, ) -> io::Result { + self.ensure_remote_control_allowed_io()?; clients::revoke_remote_control_client(&self.remote_control_url, &self.auth_manager, params) .await } @@ -867,19 +925,21 @@ pub async fn start_remote_control( app_server_client_name_rx: Option>, startup_mode: RemoteControlStartupMode, ) -> io::Result<(JoinHandle<()>, RemoteControlHandle)> { + let policy = config.policy; let state_db_available = state_db.is_some(); let requested_initial_enabled = startup_mode == RemoteControlStartupMode::EnabledEphemeral; - let desired_state = if !state_db_available { - RemoteControlDesiredState::Disabled - } else { - match startup_mode { - RemoteControlStartupMode::ResolvePersisted => RemoteControlDesiredState::Unknown, - RemoteControlStartupMode::DisabledEphemeral => RemoteControlDesiredState::Disabled, - RemoteControlStartupMode::EnabledEphemeral => RemoteControlDesiredState::Enabled { - persistence_preference: None, - }, - } - }; + let desired_state = + if policy == RemoteControlPolicy::DisabledByRequirements || !state_db_available { + RemoteControlDesiredState::Disabled + } else { + match startup_mode { + RemoteControlStartupMode::ResolvePersisted => RemoteControlDesiredState::Unknown, + RemoteControlStartupMode::DisabledEphemeral => RemoteControlDesiredState::Disabled, + RemoteControlStartupMode::EnabledEphemeral => RemoteControlDesiredState::Enabled { + persistence_preference: None, + }, + } + }; let initial_enabled = desired_state.is_enabled(); if requested_initial_enabled && !state_db_available { warn!("remote control disabled because sqlite state db is unavailable"); @@ -995,6 +1055,7 @@ pub async fn start_remote_control( Ok(( join_handle, RemoteControlHandle { + policy, desired_state_tx, desired_state_rpc_lock, desired_state_persistence_lock, 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 03bb1efaf..8e75c0fc8 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 @@ -230,6 +230,7 @@ async fn explicit_disabled_start_ignores_persisted_enable() { RemoteControlStartConfig { remote_control_url: TEST_REMOTE_CONTROL_URL.to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager(), @@ -261,6 +262,105 @@ async fn explicit_disabled_start_ignores_persisted_enable() { remote_task.await.expect("remote control task should join"); } +#[tokio::test] +async fn managed_disable_overrides_startup_and_persisted_enablement() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = normalize_remote_control_url(&remote_control_url) + .expect("remote control target should normalize"); + let enrollment = RemoteControlEnrollmentRecord { + websocket_url: remote_control_target.websocket_url, + account_id: "account_id".to_string(), + app_server_client_name: None, + server_id: "server-id".to_string(), + environment_id: "environment-id".to_string(), + server_name: "server-name".to_string(), + remote_control_enabled: Some(true), + }; + state_db + .upsert_remote_control_enrollment(&enrollment) + .await + .expect("enrollment should persist"); + let (transport_event_tx, _transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + + let (remote_task, remote_handle) = start_remote_control( + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::DisabledByRequirements, + }, + Some(state_db.clone()), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + RemoteControlStartupMode::EnabledEphemeral, + ) + .await + .expect("remote control should start disabled"); + + assert_eq!( + remote_handle.status().status, + RemoteControlConnectionStatus::Disabled + ); + assert_eq!( + remote_handle.ensure_remote_control_allowed(), + Err(RemoteControlDisabledByRequirements) + ); + assert!( + !remote_handle + .resolve_persisted_preference(/*app_server_client_name*/ None) + .await + .expect("managed disable should resolve without loading persistence") + ); + assert_eq!( + remote_handle + .enable_ephemeral() + .expect_err("managed requirements should reject ephemeral enable"), + RemoteControlEnableError::DisabledByRequirements(RemoteControlDisabledByRequirements) + ); + let enable_error = remote_handle + .enable(/*app_server_client_name*/ None) + .await + .expect_err("managed requirements should reject durable enable"); + assert_eq!(enable_error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!( + enable_error.to_string(), + "remote control is disabled by managed requirements" + ); + let disable_error = remote_handle + .disable(/*app_server_client_name*/ None) + .await + .expect_err("managed requirements should reject durable disable"); + assert_eq!(disable_error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!( + disable_error.to_string(), + "remote control is disabled by managed requirements" + ); + assert_eq!( + state_db + .get_remote_control_enrollment( + &enrollment.websocket_url, + &enrollment.account_id, + /*app_server_client_name*/ None, + ) + .await + .expect("enrollment should load"), + Some(enrollment) + ); + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("managed requirements should prevent backend contact"); + + shutdown_token.cancel(); + remote_task.await.expect("remote control task should join"); +} + fn remote_control_url_for_listener(listener: &TcpListener) -> String { let addr = listener .local_addr() @@ -303,6 +403,7 @@ fn remote_control_handle_with_current_enrollment( }, ))); RemoteControlHandle { + policy: RemoteControlPolicy::Allowed, desired_state_tx: Arc::new(desired_state_tx), desired_state_rpc_lock: Arc::new(Semaphore::new(1)), desired_state_persistence_lock: Arc::new(Semaphore::new(1)), @@ -430,6 +531,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager(), @@ -723,6 +825,7 @@ async fn remote_control_transport_reconnects_after_disconnect() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), @@ -824,6 +927,7 @@ async fn remote_control_transport_refreshes_server_token_after_websocket_unautho RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), @@ -904,6 +1008,7 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() RemoteControlStartConfig { remote_control_url: "https://internal.example.com/backend-api/".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, /*state_db*/ None, remote_control_auth_manager(), @@ -944,6 +1049,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), auth_manager, @@ -979,6 +1085,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, /*state_db*/ None, remote_control_auth_manager(), @@ -1008,7 +1115,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( remote_handle .enable_ephemeral() .expect_err("enable should fail"), - super::RemoteControlUnavailable + RemoteControlEnableError::Unavailable(super::RemoteControlUnavailable) ); timeout(Duration::from_millis(100), listener.accept()) .await @@ -1038,6 +1145,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), @@ -1157,6 +1265,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), @@ -1339,6 +1448,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), @@ -1587,6 +1697,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), @@ -1695,6 +1806,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), @@ -1778,6 +1890,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), auth_manager.clone(), @@ -1881,6 +1994,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), auth_manager.clone(), @@ -1996,6 +2110,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), @@ -2118,6 +2233,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404() RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), @@ -2254,6 +2370,7 @@ async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_f RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), @@ -2356,6 +2473,7 @@ async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_4 RemoteControlStartConfig { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), + policy: RemoteControlPolicy::Allowed, }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 21e06a6d5..630ce8c6b 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -22,6 +22,7 @@ fn client_management_handle( environment_id: None, }); RemoteControlHandle { + policy: RemoteControlPolicy::Allowed, desired_state_tx: Arc::new(desired_state_tx), desired_state_rpc_lock: Arc::new(Semaphore::new(1)), desired_state_persistence_lock: Arc::new(Semaphore::new(1)), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 206cb4803..5840c52d0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -234,7 +234,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index b5ca8938c..3b02d7e3d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -32,6 +32,7 @@ use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; +use crate::transport::RemoteControlPolicy; use crate::transport::RemoteControlStartConfig; use crate::transport::TransportEvent; use crate::transport::acquire_app_server_startup_lock; @@ -672,6 +673,28 @@ pub async fn run_main_with_transport_options( None => error!("{}", warning.summary), } } + let remote_control_policy = if config + .config_layer_stack + .requirements() + .allow_remote_control + .as_ref() + .is_some_and(|requirement| !requirement.value) + { + RemoteControlPolicy::DisabledByRequirements + } else { + RemoteControlPolicy::Allowed + }; + let remote_control_startup_mode = runtime_options.remote_control_startup_mode; + let remote_control_explicitly_requested = + remote_control_startup_mode == RemoteControlStartupMode::EnabledEphemeral; + if remote_control_explicitly_requested + && remote_control_policy == RemoteControlPolicy::DisabledByRequirements + { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "remote control is disabled by managed requirements", + )); + } let installation_id = resolve_installation_id(&config.codex_home).await?; let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); @@ -719,10 +742,9 @@ 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_startup_mode = runtime_options.remote_control_startup_mode; - let remote_control_explicitly_requested = - remote_control_startup_mode == RemoteControlStartupMode::EnabledEphemeral; - let remote_control_enabled = remote_control_explicitly_requested && state_db.is_some(); + let remote_control_enabled = remote_control_policy == RemoteControlPolicy::Allowed + && remote_control_explicitly_requested + && state_db.is_some(); if remote_control_explicitly_requested && state_db.is_none() { error!("remote control disabled because sqlite state db is unavailable"); } @@ -733,7 +755,9 @@ pub async fn run_main_with_transport_options( { return Err(std::io::Error::new( ErrorKind::InvalidInput, - if remote_control_explicitly_requested && state_db.is_none() { + if remote_control_policy == RemoteControlPolicy::DisabledByRequirements { + "no transport configured; remote control disabled by managed requirements" + } else if remote_control_explicitly_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" @@ -745,6 +769,7 @@ pub async fn run_main_with_transport_options( RemoteControlStartConfig { remote_control_url: config.chatgpt_base_url.clone(), installation_id: installation_id.clone(), + policy: remote_control_policy, }, state_db.clone(), auth_manager.clone(), @@ -772,7 +797,11 @@ pub async fn run_main_with_transport_options( let _ = remote_control_accept_handle.await; return Err(std::io::Error::new( ErrorKind::InvalidInput, - "no transport configured; use --listen or enable remote control", + if remote_control_policy == RemoteControlPolicy::DisabledByRequirements { + "no transport configured; remote control disabled by managed requirements" + } else { + "no transport configured; use --listen or enable remote control" + }, )); } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6747830b8..aea049084 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -94,6 +94,17 @@ use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); +fn deserialize_client_request( + request: &JSONRPCRequest, +) -> Result { + serde_json::to_value(request) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + .and_then(|request_json| { + serde_json::from_value(request_json) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + }) +} + #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -582,12 +593,7 @@ impl MessageProcessor { Arc::clone(&self.outgoing), request_context.clone(), async { - let codex_request = serde_json::to_value(&request) - .map_err(|err| invalid_request(format!("Invalid request: {err}"))) - .and_then(|request_json| { - serde_json::from_value::(request_json) - .map_err(|err| invalid_request(format!("Invalid request: {err}"))) - }); + let codex_request = deserialize_client_request(&request); let result = match codex_request { Ok(codex_request) => { // Websocket callers finalize outbound readiness in lib.rs after mirroring 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 bdc801395..837018107 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -364,6 +364,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, allow_appshots: requirements.allow_appshots, + allow_remote_control: requirements.allow_remote_control, computer_use: requirements .computer_use .map(map_computer_use_requirements_to_api), @@ -618,6 +619,16 @@ mod tests { assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_allow_remote_control() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_remote_control: Some(false), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_remote_control, Some(false)); + } + #[test] fn requirements_api_includes_computer_use_requirements() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { 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 index fb94bb6b2..73833c6df 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -1,5 +1,6 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; +use crate::transport::RemoteControlEnableError; use crate::transport::RemoteControlHandle; use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; @@ -35,7 +36,7 @@ impl RemoteControlRequestProcessor { ) -> Result { let handle = self.handle()?; let status = if ephemeral { - handle.enable_ephemeral().map_err(map_unavailable)? + handle.enable_ephemeral().map_err(map_enable_error)? } else { handle .enable(app_server_client_name) @@ -88,7 +89,8 @@ impl RemoteControlRequestProcessor { params: RemoteControlPairingStatusParams, ) -> Result { validate_pairing_status_params(¶ms)?; - self.handle()? + let handle = self.handle()?; + handle .pairing_status(params) .await .map_err(map_pairing_start_error) @@ -115,9 +117,21 @@ impl RemoteControlRequestProcessor { } fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { - self.remote_control_handle + let handle = self + .remote_control_handle .as_ref() - .ok_or_else(|| internal_error("remote control is unavailable for this app-server")) + .ok_or_else(|| internal_error("remote control is unavailable for this app-server"))?; + handle + .ensure_remote_control_allowed() + .map_err(|err| invalid_request(err.to_string()))?; + Ok(handle) + } +} + +fn map_enable_error(err: RemoteControlEnableError) -> JSONRPCErrorError { + match err { + RemoteControlEnableError::Unavailable(err) => map_unavailable(err), + RemoteControlEnableError::DisabledByRequirements(err) => invalid_request(err.to_string()), } } @@ -126,7 +140,10 @@ fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError { } fn map_update_error(err: io::Error) -> JSONRPCErrorError { - if err.kind() == io::ErrorKind::NotFound { + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied + ) { invalid_request(err.to_string()) } else { internal_error(err.to_string()) diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 94c1ec05f..9c4065376 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -18,7 +18,9 @@ pub(crate) use codex_app_server_transport::ConnectionId; pub(crate) use codex_app_server_transport::ConnectionOrigin; pub(crate) use codex_app_server_transport::OutgoingMessage; pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; +pub(crate) use codex_app_server_transport::RemoteControlEnableError; pub(crate) use codex_app_server_transport::RemoteControlHandle; +pub(crate) use codex_app_server_transport::RemoteControlPolicy; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub use codex_app_server_transport::RemoteControlStartupMode; pub(crate) use codex_app_server_transport::RemoteControlUnavailable; diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index b2ecb6473..4d2c4206c 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -1133,6 +1133,11 @@ impl TestAppServer { self.send_request("config/read", params).await } + pub async fn send_config_requirements_read_request(&mut self) -> anyhow::Result { + self.send_request("configRequirements/read", /*params*/ None) + .await + } + pub async fn send_config_value_write_request( &mut self, params: ConfigValueWriteParams, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 343b91b8d..bfb7d8172 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ForcedChatgptWorkspaceIds; @@ -47,6 +48,33 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { )?) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_requirements_read_includes_allow_remote_control() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allow_remote_control = false\n", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_config_requirements_read_request().await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ConfigRequirementsReadResponse = to_response(response)?; + assert_eq!( + response + .requirements + .expect("managed requirements should be returned") + .allow_remote_control, + Some(false) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_returns_effective_and_layers() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index 920e9bfa6..43076d164 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -1,3 +1,6 @@ +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io::ErrorKind; use std::time::Duration; use anyhow::Context; @@ -8,6 +11,13 @@ use app_test_support::TestAppServer; 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::AppServerRuntimeOptions; +use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthSettings; +use codex_app_server::PluginStartupTasks; +use codex_app_server::RemoteControlStartupMode; +use codex_app_server::run_main_with_transport_options; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RemoteControlClient; use codex_app_server_protocol::RemoteControlClientsListOrder; @@ -22,12 +32,18 @@ use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RemoteControlPairingStatusResponse; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::protocol::SessionSource; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; +use codex_utils_cli::CliConfigOverrides; use pretty_assertions::assert_eq; +use serial_test::serial; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; @@ -41,6 +57,34 @@ use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_CONTROL_DISABLED_BY_REQUIREMENTS_MESSAGE: &str = + "remote control is disabled by managed requirements"; + +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &OsStr) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} async fn remote_control_preference( state_db: &StateRuntime, @@ -61,6 +105,149 @@ async fn wait_for_response(mcp: &mut TestAppServer, request_id: i64) -> Result Result<()> { + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(error.code, -32600); + assert_eq!( + error.message, + REMOTE_CONTROL_DISABLED_BY_REQUIREMENTS_MESSAGE + ); + Ok(()) +} + +#[tokio::test] +async fn managed_requirements_reject_all_remote_control_rpcs() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allow_remote_control = false\n", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("remoteControl/status/changed"), + ) + .await??; + let status: RemoteControlStatusChangedNotification = serde_json::from_value( + notification + .params + .context("remote-control status notification should include params")?, + )?; + assert_eq!(status.status, RemoteControlConnectionStatus::Disabled); + assert_eq!(status.environment_id, None); + + let request_ids = [ + mcp.send_remote_control_enable_request().await?, + mcp.send_remote_control_disable_request().await?, + mcp.send_remote_control_status_read_request().await?, + mcp.send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: false, + }) + .await?, + mcp.send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await?, + mcp.send_remote_control_clients_list_request(RemoteControlClientsListParams { + environment_id: "environment-id".to_string(), + cursor: None, + limit: None, + order: None, + }) + .await?, + mcp.send_remote_control_clients_revoke_request(RemoteControlClientsRevokeParams { + environment_id: "environment-id".to_string(), + client_id: "client-id".to_string(), + }) + .await?, + ]; + + for request_id in request_ids { + assert_remote_control_disabled_by_requirements(&mut mcp, request_id).await?; + } + + Ok(()) +} + +#[tokio::test] +async fn managed_requirements_allow_remote_control_true_does_not_enable_or_block_it() -> Result<()> +{ + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allow_remote_control = true\n", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + Ok(()) +} + +#[tokio::test] +#[serial] +async fn explicit_remote_control_startup_fails_when_disabled_by_requirements() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allow_remote_control = false\n", + )?; + let managed_config_path = codex_home.path().join("managed_config.toml"); + let socket_path = codex_home.path().join("app-server.sock"); + let transport = + AppServerTransport::from_listen_url(&format!("unix://{}", socket_path.display()))?; + let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); + + let result = timeout( + STARTUP_TIMEOUT, + run_main_with_transport_options( + Arg0DispatchPaths { + codex_self_exe: Some(std::env::current_exe()?), + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + }, + CliConfigOverrides::default(), + LoaderOverrides::with_managed_config_path_for_tests(managed_config_path), + /*strict_config*/ false, + /*default_analytics_enabled*/ false, + transport, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions { + plugin_startup_tasks: PluginStartupTasks::Skip, + remote_control_startup_mode: RemoteControlStartupMode::EnabledEphemeral, + install_shutdown_signal_handler: false, + }, + ), + ) + .await?; + let err = result.expect_err("managed requirements should reject explicit remote control"); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + REMOTE_CONTROL_DISABLED_BY_REQUIREMENTS_MESSAGE + ); + assert!(!socket_path.exists()); + Ok(()) +} + #[tokio::test] async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { let codex_home = TempDir::new()?; @@ -88,6 +275,54 @@ async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> { Ok(()) } +#[tokio::test] +async fn listen_off_ignores_persisted_enable_when_disabled_by_requirements() -> Result<()> { + let codex_home = TempDir::new()?; + let listener = configured_remote_control_listener(codex_home.path()).await?; + std::fs::write( + codex_home.path().join("requirements.toml"), + "allow_remote_control = false\n", + )?; + let websocket_url = format!( + "ws://{}/backend-api/wham/remote/control/server", + listener.local_addr()? + ); + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; + state_db + .upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord { + websocket_url: websocket_url.clone(), + account_id: "account_id".to_string(), + app_server_client_name: None, + server_id: "server-id".to_string(), + environment_id: "environment-id".to_string(), + server_name: "server-name".to_string(), + remote_control_enabled: Some(true), + }) + .await?; + + let mut app_server = + TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?; + let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??; + assert!(!status.success()); + timeout(Duration::from_millis(100), listener.accept()) + .await + .expect_err("managed requirements should prevent a remote-control connection"); + assert_eq!( + state_db + .get_remote_control_enrollment( + &websocket_url, + "account_id", + /*app_server_client_name*/ None + ) + .await? + .context("enrollment should remain persisted")? + .remote_control_enabled, + Some(true) + ); + Ok(()) +} + #[tokio::test] async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<()> { for persisted_preference in [None, Some(false)] { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 1fbdebbd0..ca4407ce4 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -149,6 +149,7 @@ pub struct ConfigRequirements { pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, + pub allow_remote_control: Option>, pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, @@ -189,6 +190,7 @@ impl Default for ConfigRequirements { ), allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, feature_requirements: None, managed_hooks: None, @@ -828,6 +830,7 @@ pub struct ConfigRequirementsToml { pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, + pub allow_remote_control: Option, pub computer_use: Option, pub windows: Option, #[serde(rename = "features", alias = "feature_requirements")] @@ -882,6 +885,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, + pub allow_remote_control: Option>, pub computer_use: Option>, pub windows: Option>, pub feature_requirements: Option>, @@ -924,6 +928,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes: _, allow_managed_hooks_only: _, allow_appshots: _, + allow_remote_control: _, computer_use: _, windows: _, feature_requirements: _, @@ -959,6 +964,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, windows, feature_requirements, @@ -992,6 +998,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, windows, feature_requirements, @@ -1015,6 +1022,7 @@ impl ConfigRequirementsWithSources { allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), allow_appshots: allow_appshots.map(|sourced| sourced.value), + allow_remote_control: allow_remote_control.map(|sourced| sourced.value), computer_use: computer_use.map(|sourced| sourced.value), windows: windows.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), @@ -1104,6 +1112,7 @@ impl ConfigRequirementsToml { && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() && self.allow_appshots.is_none() + && self.allow_remote_control.is_none() && self .computer_use .as_ref() @@ -1156,6 +1165,7 @@ impl TryFrom for ConfigRequirements { allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, windows, feature_requirements, @@ -1434,6 +1444,7 @@ impl TryFrom for ConfigRequirements { web_search_mode, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, feature_requirements, managed_hooks, @@ -1525,6 +1536,7 @@ mod tests { allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, windows, feature_requirements, @@ -1555,6 +1567,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_appshots: allow_appshots .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_remote_control: allow_remote_control + .map(|value| Sourced::new(value, RequirementSource::Unknown)), computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements @@ -1688,6 +1702,19 @@ mod tests { Ok(()) } + #[test] + fn allow_remote_control_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_remote_control = false + "#, + )?; + + assert_eq!(requirements.allow_remote_control, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn deserialize_computer_use_requirements() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( @@ -1745,6 +1772,7 @@ mod tests { allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), allow_appshots: Some(false), + allow_remote_control: Some(false), computer_use: Some(computer_use.clone()), windows: None, feature_requirements: Some(feature_requirements.clone()), @@ -1787,6 +1815,10 @@ mod tests { enforce_source.clone(), )), allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), + allow_remote_control: Some(Sourced::new( + /*value*/ false, + enforce_source.clone(), + )), computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), windows: None, feature_requirements: Some(Sourced::new( @@ -1835,6 +1867,7 @@ mod tests { allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, windows: None, feature_requirements: None, @@ -1888,6 +1921,7 @@ mod tests { allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, windows: None, feature_requirements: None, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 0396cda23..a7bcab279 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -182,6 +182,7 @@ fn populate_merged_regular_fields_with_sources( allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, + allow_remote_control, computer_use, windows, feature_requirements, @@ -210,6 +211,7 @@ fn populate_merged_regular_fields_with_sources( set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]); set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]); set_sourced!(allow_appshots, &["allow_appshots"]); + set_sourced!(allow_remote_control, &["allow_remote_control"]); set_sourced!(computer_use, &["computer_use"]); set_sourced!(windows, &["windows"]); set_sourced!(feature_requirements, &["features", "feature_requirements"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 82a99a7f0..7be51e2b3 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -63,6 +63,7 @@ fn top_level_values_use_toml_priority() { allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] default_permissions = ":workspace" +allow_remote_control = true [allowed_permission_profiles] ":read-only" = true @@ -76,6 +77,7 @@ default_permissions = ":workspace" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" +allow_remote_control = false [allowed_permission_profiles] ":danger-full-access" = false @@ -93,6 +95,7 @@ default_permissions = ":read-only" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] default_permissions = ":read-only" +allow_remote_control = false [allowed_permission_profiles] ":danger-full-access" = false @@ -135,6 +138,7 @@ fn composition_strategy_applies_to_non_cloud_layers() { format!( r#" allowed_approval_policies = ["on-request"] +allow_remote_control = true [features] shared = false @@ -154,6 +158,7 @@ deny_read = [{low_path:?}] format!( r#" allowed_approval_policies = ["never"] +allow_remote_control = false [features] shared = true @@ -178,6 +183,7 @@ deny_read = [{high_path:?}] expected_requirements(format!( r#" allowed_approval_policies = ["never"] +allow_remote_control = false [features] shared = true @@ -198,7 +204,14 @@ deny_read = [{high_path:?}, {low_path:?}] ); assert_eq!( composed.allowed_approval_policies, - Some(Sourced::new(vec![AskForApproval::Never], mdm_source)) + Some(Sourced::new( + vec![AskForApproval::Never], + mdm_source.clone() + )) + ); + assert_eq!( + composed.allow_remote_control, + Some(Sourced::new(/*value*/ false, mdm_source)) ); } diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index d5f2b050c..9d8de1c88 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -73,14 +73,18 @@ impl LoaderOverrides { } } - /// Returns overrides with host MDM disabled and managed config loaded from `managed_config_path`. + /// Returns overrides with host MDM disabled and managed config loaded from + /// `managed_config_path`. System requirements are loaded from a sibling + /// `requirements.toml` fixture. /// /// This is intended for tests that supply an explicit managed config fixture. pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { + let system_requirements_path = managed_config_path.with_file_name("requirements.toml"); Self { user_config_path: None, user_config_profile: None, managed_config_path: Some(managed_config_path), + system_requirements_path: Some(system_requirements_path), ..Self::without_managed_config_for_tests() } } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c4c24d34d..ff2f7ef24 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8304,6 +8304,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, windows: None, feature_requirements: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ecac74f5d..6cf7b9a18 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2631,6 +2631,7 @@ impl Config { web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, allow_appshots: _, + allow_remote_control: _, computer_use: _, feature_requirements, managed_hooks: _, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index d1dde975f..faacca458 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -196,6 +196,17 @@ fn render_debug_config_lines( )); } + if let Some(allow_remote_control) = requirements_toml.allow_remote_control { + requirement_lines.push(requirement_line( + "allow_remote_control", + allow_remote_control.to_string(), + requirements + .allow_remote_control + .as_ref() + .map(|sourced| &sourced.source), + )); + } + if requirements_toml.guardian_policy_config.is_some() { requirement_lines.push(requirement_line( "guardian_policy_config", @@ -712,6 +723,10 @@ mod tests { /*value*/ false, RequirementSource::LegacyManagedConfigTomlFromMdm, )), + allow_remote_control: Some(Sourced::new( + /*value*/ false, + RequirementSource::LegacyManagedConfigTomlFromMdm, + )), feature_requirements: Some(Sourced::new( FeatureRequirementsToml { entries: BTreeMap::from([("guardian_approval".to_string(), true)]), @@ -753,6 +768,7 @@ mod tests { allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), allow_appshots: Some(false), + allow_remote_control: Some(false), computer_use: None, windows: None, guardian_policy_config: Some("Use the managed guardian policy.".to_string()), @@ -795,6 +811,9 @@ mod tests { .expect("config layer stack"); let rendered = render_stack_to_text(&stack); + #[cfg(not(windows))] + insta::assert_snapshot!("debug_config_requirement_sources", rendered.as_str()); + let requirements_source = (RequirementSource::LegacyManagedConfigTomlFromMdm).to_string(); assert!(rendered.contains(&format!( "allowed_approval_policies: on-request (source: {requirements_source})" @@ -820,6 +839,9 @@ mod tests { assert!(rendered.contains(&format!( "allow_appshots: false (source: {requirements_source})" ))); + assert!(rendered.contains(&format!( + "allow_remote_control: false (source: {requirements_source})" + ))); assert!(rendered.contains(&format!( "guardian_policy_config: configured (source: {requirements_source})" ))); @@ -1112,6 +1134,7 @@ approval_policy = "never" allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, allow_appshots: None, + allow_remote_control: None, computer_use: None, windows: None, guardian_policy_config: None, diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap new file mode 100644 index 000000000..b6505f775 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_requirement_sources.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/debug_config.rs +expression: rendered.as_str() +--- +/debug-config + +Config layer stack (lowest precedence first): + 1. user (/home/alice/.codex/config.toml) (enabled) + +Requirements: + - allowed_approval_policies: on-request (source: MDM managed_config.toml (legacy)) + - allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy)) + - allowed_sandbox_modes: read-only (source: /etc/codex/requirements.toml) + - allowed_web_search_modes: cached, disabled (source: MDM managed_config.toml (legacy)) + - allow_managed_hooks_only: true (source: MDM managed_config.toml (legacy)) + - allow_appshots: false (source: MDM managed_config.toml (legacy)) + - allow_remote_control: false (source: MDM managed_config.toml (legacy)) + - guardian_policy_config: configured (source: MDM managed_config.toml (legacy)) + - features: guardian_approval=true (source: MDM managed_config.toml (legacy)) + - mcp_servers: docs (source: MDM managed_config.toml (legacy)) + - enforce_residency: us (source: MDM managed_config.toml (legacy)) + - experimental_network: enabled=true, domains={example.com=allow} (source: MDM managed_config.toml (legacy)) + - permissions.filesystem.deny_read: /home/alice/.gitconfig (source: /etc/codex/requirements.toml)