Compare commits

...

1 Commits

Author SHA1 Message Date
Anton Panasenko
191dc00a71 feat: add remote control enrollment read 2026-04-29 16:07:30 -07:00
13 changed files with 218 additions and 5 deletions

View File

@@ -751,6 +751,11 @@ client_request_definitions! {
serialization: None,
response: v2::ModelProviderCapabilitiesReadResponse,
},
RemoteControlEnrollmentRead => "remoteControl/enrollment/read" {
params: v2::RemoteControlEnrollmentReadParams,
serialization: None,
response: v2::RemoteControlEnrollmentReadResponse,
},
ExperimentalFeatureList => "experimentalFeature/list" {
params: v2::ExperimentalFeatureListParams,
serialization: global("config"),
@@ -2388,6 +2393,23 @@ mod tests {
Ok(())
}
#[test]
fn serialize_remote_control_enrollment_read() -> Result<()> {
let request = ClientRequest::RemoteControlEnrollmentRead {
request_id: RequestId::Integer(7),
params: v2::RemoteControlEnrollmentReadParams {},
};
assert_eq!(
json!({
"method": "remoteControl/enrollment/read",
"id": 7,
"params": {}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_list_collaboration_modes() -> Result<()> {
let request = ClientRequest::CollaborationModeList {

View File

@@ -2946,6 +2946,26 @@ pub struct RemoteControlStatusChangedNotification {
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlEnrollmentReadParams {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlEnrollmentReadResponse {
pub enrollment: Option<RemoteControlEnrollment>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlEnrollment {
pub server_id: String,
pub environment_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]

View File

@@ -206,6 +206,7 @@ Example with notification opt-out:
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
- `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`.
- `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API.
- `remoteControl/enrollment/read` — read the local remote-control enrollment for the initialized app-server client and current ChatGPT account. Returns `enrollment: null` when no matching enrollment is available; otherwise the enrollment contains only `serverId` and `environmentId`.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).

View File

@@ -1310,6 +1310,11 @@ impl CodexMessageProcessor {
"ModelProviderCapabilitiesRead request reached CodexMessageProcessor unexpectedly"
);
}
ClientRequest::RemoteControlEnrollmentRead { .. } => {
warn!(
"RemoteControlEnrollmentRead request reached CodexMessageProcessor unexpectedly"
);
}
ClientRequest::ExternalAgentConfigDetect { .. }
| ClientRequest::ExternalAgentConfigImport { .. } => {
warn!("ExternalAgentConfig request reached CodexMessageProcessor unexpectedly");

View File

@@ -399,6 +399,12 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
});
let processor_outgoing = Arc::clone(&outgoing_message_sender);
let state_db = codex_state::StateRuntime::init(
args.config.sqlite_home.clone(),
args.config.model_provider_id.clone(),
)
.await
.ok();
let config_manager = ConfigManager::new(
args.config.codex_home.to_path_buf(),
args.cli_overrides,
@@ -418,6 +424,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
environment_manager: args.environment_manager,
feedback: args.feedback,
log_db: args.log_db,
state_db,
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,

View File

@@ -749,6 +749,7 @@ pub async fn run_main_with_transport_options(
environment_manager,
feedback: feedback.clone(),
log_db,
state_db,
config_warnings,
session_source,
auth_manager,

View File

@@ -11,6 +11,7 @@ use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::device_key_api::DeviceKeyApi;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
@@ -25,6 +26,7 @@ use crate::request_serialization::RequestSerializationQueues;
use crate::transport::AppServerTransport;
use crate::transport::ConnectionOrigin;
use crate::transport::RemoteControlHandle;
use crate::transport::normalize_remote_control_url;
use async_trait::async_trait;
use axum::http::HeaderValue;
use codex_analytics::AnalyticsEventsClient;
@@ -58,6 +60,8 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::RemoteControlEnrollment;
use codex_app_server_protocol::RemoteControlEnrollmentReadResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
@@ -83,6 +87,7 @@ use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_state::StateRuntime;
use codex_state::log_db::LogDbLayer;
use futures::FutureExt;
use tokio::sync::broadcast;
@@ -169,6 +174,7 @@ pub(crate) struct MessageProcessor {
external_agent_config_api: ExternalAgentConfigApi,
fs_api: FsApi,
auth_manager: Arc<AuthManager>,
state_db: Option<Arc<StateRuntime>>,
analytics_events_client: AnalyticsEventsClient,
fs_watch_manager: FsWatchManager,
config: Arc<Config>,
@@ -255,6 +261,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) environment_manager: Arc<EnvironmentManager>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
pub(crate) state_db: Option<Arc<StateRuntime>>,
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
pub(crate) session_source: SessionSource,
pub(crate) auth_manager: Arc<AuthManager>,
@@ -276,6 +283,7 @@ impl MessageProcessor {
environment_manager,
feedback,
log_db,
state_db,
config_warnings,
session_source,
auth_manager,
@@ -351,6 +359,7 @@ impl MessageProcessor {
external_agent_config_api,
fs_api,
auth_manager,
state_db,
analytics_events_client,
fs_watch_manager,
config,
@@ -946,6 +955,17 @@ impl MessageProcessor {
self.handle_model_provider_capabilities_read(request_id_for_connection(request_id))
.await;
}
ClientRequest::RemoteControlEnrollmentRead {
request_id,
params: _,
} => {
let result = self
.remote_control_enrollment_read(app_server_client_name.as_deref())
.await;
self.outgoing
.send_result(request_id_for_connection(request_id), result)
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
@@ -983,6 +1003,41 @@ impl MessageProcessor {
self.outgoing.send_result(request_id, result).await;
}
async fn remote_control_enrollment_read(
&self,
app_server_client_name: Option<&str>,
) -> Result<RemoteControlEnrollmentReadResponse, JSONRPCErrorError> {
let Some(state_db) = self.state_db.as_deref() else {
return Ok(RemoteControlEnrollmentReadResponse { enrollment: None });
};
let Some(auth) = self.auth_manager.auth().await else {
return Ok(RemoteControlEnrollmentReadResponse { enrollment: None });
};
let Some(account_id) = auth.get_account_id() else {
return Ok(RemoteControlEnrollmentReadResponse { enrollment: None });
};
let remote_control_target = normalize_remote_control_url(&self.config.chatgpt_base_url)
.map_err(|err| {
internal_error(format!("failed to resolve remote control URL: {err}"))
})?;
let enrollment = state_db
.get_remote_control_enrollment(
&remote_control_target.websocket_url,
&account_id,
app_server_client_name,
)
.await
.map_err(|err| {
internal_error(format!("failed to read remote control enrollment: {err}"))
})?
.map(|enrollment| RemoteControlEnrollment {
server_id: enrollment.server_id,
environment_id: enrollment.environment_id,
});
Ok(RemoteControlEnrollmentReadResponse { enrollment })
}
async fn handle_config_value_write(
&self,
request_id: ConnectionRequestId,

View File

@@ -290,6 +290,7 @@ async fn build_test_processor(
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
auth_manager,

View File

@@ -41,6 +41,7 @@ mod unix_socket_tests;
mod websocket;
pub(crate) use remote_control::RemoteControlHandle;
pub(crate) use remote_control::normalize_remote_control_url;
pub(crate) use remote_control::start_remote_control;
pub(crate) use stdio::start_stdio_connection;
pub(crate) use unix_socket::start_control_socket_acceptor;

View File

@@ -10,7 +10,7 @@ use crate::transport::remote_control::websocket::RemoteControlWebsocket;
pub use self::protocol::ClientId;
use self::protocol::ServerEvent;
use self::protocol::StreamId;
use self::protocol::normalize_remote_control_url;
pub(crate) use self::protocol::normalize_remote_control_url;
use super::CHANNEL_CAPACITY;
use super::TransportEvent;
use super::next_connection_id;

View File

@@ -8,9 +8,9 @@ use url::Host;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RemoteControlTarget {
pub(super) websocket_url: String,
pub(super) enroll_url: String,
pub(crate) struct RemoteControlTarget {
pub(crate) websocket_url: String,
pub(crate) enroll_url: String,
}
#[derive(Debug, Serialize)]
@@ -124,7 +124,7 @@ fn is_localhost(host: &Option<Host<&str>>) -> bool {
}
}
pub(super) fn normalize_remote_control_url(
pub(crate) fn normalize_remote_control_url(
remote_control_url: &str,
) -> io::Result<RemoteControlTarget> {
let map_url_parse_error = |err: url::ParseError| -> io::Error {

View File

@@ -35,6 +35,7 @@ mod plugin_read;
mod plugin_uninstall;
mod rate_limits;
mod realtime_conversation;
mod remote_control_enrollment_read;
#[cfg(debug_assertions)]
mod remote_thread_store;
mod request_permissions;

View File

@@ -0,0 +1,99 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::RemoteControlEnrollment;
use codex_app_server_protocol::RemoteControlEnrollmentReadResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const CHATGPT_BASE_URL: &str = "https://chatgpt.com/backend-api";
const REMOTE_CONTROL_WEBSOCKET_URL: &str =
"wss://chatgpt.com/backend-api/wham/remote/control/server";
#[tokio::test]
async fn reads_persisted_remote_control_enrollment_for_initialized_client() -> Result<()> {
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
"http://localhost:0",
CHATGPT_BASE_URL,
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-token")
.account_id("account-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let state_db =
StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?;
state_db
.upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord {
websocket_url: REMOTE_CONTROL_WEBSOCKET_URL.to_string(),
account_id: "account-123".to_string(),
app_server_client_name: Some(DEFAULT_CLIENT_NAME.to_string()),
server_id: "srv_e_test".to_string(),
environment_id: "env_test".to_string(),
server_name: "test-server".to_string(),
})
.await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
assert_eq!(
read_remote_control_enrollment(&mut mcp).await?,
RemoteControlEnrollmentReadResponse {
enrollment: Some(RemoteControlEnrollment {
server_id: "srv_e_test".to_string(),
environment_id: "env_test".to_string(),
}),
}
);
Ok(())
}
#[tokio::test]
async fn returns_null_when_remote_control_enrollment_is_unknown() -> Result<()> {
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
"http://localhost:0",
CHATGPT_BASE_URL,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
assert_eq!(
read_remote_control_enrollment(&mut mcp).await?,
RemoteControlEnrollmentReadResponse { enrollment: None }
);
Ok(())
}
async fn read_remote_control_enrollment(
mcp: &mut McpProcess,
) -> Result<RemoteControlEnrollmentReadResponse> {
let request_id = mcp
.send_raw_request("remoteControl/enrollment/read", Some(serde_json::json!({})))
.await?;
let response = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}