feat(remote-control): add daemon pairing command (#29913)

## Why

Users who run Codex remote control through daemon mode can keep the
daemon running, but they do not have a CLI path to mint the short-lived
manual pairing code needed to connect another device. Without this
command, they need to speak app-server JSON-RPC directly.

Related: #25675

## What Changed

- Added `codex remote-control pair`, which connects to the existing
daemon control socket and calls `remoteControl/pairing/start` with
`manualCode: true`.
- Kept the command non-lifecycle-mutating: it does not start, enable, or
restart the daemon.
- Human output labels the manual code as `Pairing code: ...`; `--json`
preserves the full pairing response.
- Added daemon socket-client, CLI formatting, and parser coverage.

## Verification

- `remote_control_client::tests::start_pairing_requests_manual_code`
verifies the daemon client sends `{ "manualCode": true }` and parses the
complete response.
-
`remote_control_cmd::tests::remote_control_pairing_human_output_labels_the_manual_code`
verifies the human-facing output.
This commit is contained in:
Anton Panasenko
2026-06-24 18:00:06 -07:00
committed by GitHub
parent 35f5d02464
commit f4e6aa70e5
4 changed files with 171 additions and 0 deletions
+8
View File
@@ -15,6 +15,7 @@ use anyhow::anyhow;
pub use backend::BackendKind;
use backend::BackendPaths;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_transport::app_server_control_socket_path;
use codex_utils_home_dir::find_codex_home;
use managed_install::managed_codex_bin;
@@ -225,6 +226,13 @@ pub async fn enable_remote_control_on_socket(
.await
}
/// Starts a manual pairing session through an already-running daemon app-server.
pub async fn start_remote_control_pairing() -> Result<RemoteControlPairingStartResponse> {
ensure_supported_platform()?;
let daemon = Daemon::from_environment()?;
remote_control_client::start_pairing(&daemon.socket_path).await
}
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
ensure_supported_platform()?;
Daemon::from_environment()?.set_remote_control(mode).await
@@ -12,6 +12,8 @@ use codex_app_server_protocol::RemoteControlDisableParams;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableParams;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_app_server_protocol::RequestId;
use serde::de::DeserializeOwned;
@@ -53,6 +55,35 @@ pub(crate) async fn disable_remote_control(socket_path: &Path) -> Result<RemoteC
Ok(RemoteControlReadyStatus::from(response))
}
pub(crate) async fn start_pairing(socket_path: &Path) -> Result<RemoteControlPairingStartResponse> {
let mut websocket = client::connect(socket_path).await?;
initialize_client(&mut websocket).await?;
let params = serde_json::to_value(RemoteControlPairingStartParams { manual_code: true })?;
send_remote_control_request(
&mut websocket,
REMOTE_CONTROL_REQUEST_ID.clone(),
"remoteControl/pairing/start",
Some(params),
)
.await?;
let response = match read_remote_control_response(
&mut websocket,
&REMOTE_CONTROL_REQUEST_ID,
"remoteControl/pairing/start",
)
.await?
{
RemoteControlRpcResponse::Success(response) => response,
RemoteControlRpcResponse::InvalidParams => {
return Err(anyhow!(
"remoteControl/pairing/start rejected manual pairing parameters"
));
}
};
websocket.close(None).await.ok();
Ok(response)
}
pub(crate) async fn enable_remote_control_with_connect_retry(
socket_path: &Path,
connect_timeout: Duration,
@@ -538,6 +569,53 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn start_pairing_requests_manual_code() -> Result<()> {
let dir = TempDir::new()?;
let socket_path = dir.path().join("app-server.sock");
let listener = UnixListener::bind(&socket_path).await?;
let server_task = tokio::spawn(async move {
let mut websocket = accept_initialized_client(listener).await?;
let pairing = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(pairing) = pairing else {
panic!("expected remoteControl/pairing/start request");
};
assert_eq!(pairing.id, REMOTE_CONTROL_REQUEST_ID);
assert_eq!(pairing.method, "remoteControl/pairing/start");
assert_eq!(
pairing.params,
Some(serde_json::json!({ "manualCode": true }))
);
client::send_message(
&mut websocket,
&JSONRPCMessage::Response(JSONRPCResponse {
id: REMOTE_CONTROL_REQUEST_ID,
result: serde_json::to_value(RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 1_700_000_000,
})?,
}),
)
.await?;
Ok::<_, anyhow::Error>(())
});
let response = start_pairing(&socket_path).await?;
server_task.await??;
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 1_700_000_000,
}
);
Ok(())
}
struct EnableScenario {
initial_notification: Option<RemoteControlStatusChangedNotification>,
enable_response: RemoteControlStatusChangedNotification,