From f4e6aa70e55426b39e560e043228c665be092e23 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 24 Jun 2026 18:00:06 -0700 Subject: [PATCH] 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. --- codex-rs/app-server-daemon/src/lib.rs | 8 ++ .../src/remote_control_client.rs | 78 +++++++++++++++++++ codex-rs/cli/src/main.rs | 9 +++ codex-rs/cli/src/remote_control_cmd.rs | 76 ++++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 21fecef1c..5870075fe 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -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 { + 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 { ensure_supported_platform()?; Daemon::from_environment()?.set_remote_control(mode).await diff --git a/codex-rs/app-server-daemon/src/remote_control_client.rs b/codex-rs/app-server-daemon/src/remote_control_client.rs index 447e22ac6..74c978f21 100644 --- a/codex-rs/app-server-daemon/src/remote_control_client.rs +++ b/codex-rs/app-server-daemon/src/remote_control_client.rs @@ -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 Result { + 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, enable_response: RemoteControlStatusChangedNotification, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8fd1358ef..959ec3a3b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -3559,6 +3559,15 @@ mod tests { assert!(err.to_string().contains("remote-control")); } + #[test] + fn remote_control_pair_parses() { + let cli = MultitoolCli::try_parse_from(["codex", "remote-control", "pair"]).expect("parse"); + let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { + panic!("expected remote-control subcommand"); + }; + assert_eq!(remote_control.subcommand_name(), "remote-control pair"); + } + #[test] fn remote_flag_parses_for_interactive_root() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"]) diff --git a/codex-rs/cli/src/remote_control_cmd.rs b/codex-rs/cli/src/remote_control_cmd.rs index 85434dbb6..20f4f4500 100644 --- a/codex-rs/cli/src/remote_control_cmd.rs +++ b/codex-rs/cli/src/remote_control_cmd.rs @@ -13,6 +13,7 @@ use codex_app_server_daemon::RemoteControlReadyOutput as AppServerRemoteControlR use codex_app_server_daemon::RemoteControlReadyStatus as AppServerRemoteControlReadyStatus; use codex_app_server_daemon::RemoteControlStartOutput as AppServerRemoteControlStartOutput; use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlPairingStartResponse; use codex_arg0::Arg0DispatchPaths; use codex_config::LoaderOverrides; use codex_protocol::protocol::SessionSource; @@ -43,6 +44,7 @@ impl RemoteControlCommand { None => "remote-control", Some(RemoteControlSubcommand::Start) => "remote-control start", Some(RemoteControlSubcommand::Stop) => "remote-control stop", + Some(RemoteControlSubcommand::Pair) => "remote-control pair", } } } @@ -54,6 +56,9 @@ enum RemoteControlSubcommand { /// Stop the app-server daemon. Stop, + + /// Create and print a short-lived manual pairing code. + Pair, } pub(crate) async fn run( @@ -82,6 +87,10 @@ pub(crate) async fn run( let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Stop).await?; print_remote_control_stop_output(&output, command.json)?; } + Some(RemoteControlSubcommand::Pair) => { + let output = codex_app_server_daemon::start_remote_control_pairing().await?; + print_remote_control_pairing_output(&output, command.json)?; + } } Ok(()) } @@ -451,6 +460,29 @@ fn print_remote_control_stop_output( Ok(()) } +fn print_remote_control_pairing_output( + output: &RemoteControlPairingStartResponse, + json: bool, +) -> anyhow::Result<()> { + println!("{}", format_remote_control_pairing_output(output, json)?); + Ok(()) +} + +fn format_remote_control_pairing_output( + output: &RemoteControlPairingStartResponse, + json: bool, +) -> anyhow::Result { + if json { + return Ok(serde_json::to_string(output)?); + } + + let manual_pairing_code = output + .manual_pairing_code + .as_deref() + .context("remote-control pairing response did not include a manual pairing code")?; + Ok(format!("Pairing code: {manual_pairing_code}")) +} + fn remote_control_stop_human_message(output: &AppServerLifecycleOutput) -> String { match output.status { AppServerLifecycleStatus::Stopped => "Remote control stopped.".to_string(), @@ -509,6 +541,15 @@ mod tests { } } + fn pairing_response(manual_pairing_code: Option<&str>) -> RemoteControlPairingStartResponse { + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: manual_pairing_code.map(str::to_string), + environment_id: "env_test".to_string(), + expires_at: 1_700_000_000, + } + } + #[test] fn remote_control_human_start_messages_use_server_name() { assert_eq!( @@ -629,6 +670,41 @@ mod tests { ); } + #[test] + fn remote_control_pairing_human_output_labels_the_manual_code() { + assert_eq!( + format_remote_control_pairing_output(&pairing_response(Some("ABCD-EFGH")), false) + .expect("manual pairing output"), + "Pairing code: ABCD-EFGH" + ); + } + + #[test] + fn remote_control_pairing_json_output_preserves_pairing_artifacts() { + let output = + format_remote_control_pairing_output(&pairing_response(Some("ABCD-EFGH")), true) + .expect("pairing JSON output"); + assert_eq!( + serde_json::from_str::(&output).expect("valid JSON"), + json!({ + "pairingCode": "pairing-code", + "manualPairingCode": "ABCD-EFGH", + "environmentId": "env_test", + "expiresAt": 1_700_000_000, + }) + ); + } + + #[test] + fn remote_control_pairing_human_output_requires_manual_code() { + assert_eq!( + format_remote_control_pairing_output(&pairing_response(None), false) + .expect_err("missing manual pairing code should fail") + .to_string(), + "remote-control pairing response did not include a manual pairing code" + ); + } + #[tokio::test] async fn foreground_wait_aborts_app_server_on_stop_signal() { let app_server_task = tokio::spawn(std::future::pending::>());