mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
feat: add remote control enrollment read
This commit is contained in:
@@ -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**).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user