Skip to content

Commit f4e6aa7

Browse files
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.
1 parent 35f5d02 commit f4e6aa7

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

codex-rs/app-server-daemon/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use anyhow::anyhow;
1515
pub use backend::BackendKind;
1616
use backend::BackendPaths;
1717
use codex_app_server_protocol::RemoteControlConnectionStatus;
18+
use codex_app_server_protocol::RemoteControlPairingStartResponse;
1819
use codex_app_server_transport::app_server_control_socket_path;
1920
use codex_utils_home_dir::find_codex_home;
2021
use managed_install::managed_codex_bin;
@@ -225,6 +226,13 @@ pub async fn enable_remote_control_on_socket(
225226
.await
226227
}
227228

229+
/// Starts a manual pairing session through an already-running daemon app-server.
230+
pub async fn start_remote_control_pairing() -> Result<RemoteControlPairingStartResponse> {
231+
ensure_supported_platform()?;
232+
let daemon = Daemon::from_environment()?;
233+
remote_control_client::start_pairing(&daemon.socket_path).await
234+
}
235+
228236
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
229237
ensure_supported_platform()?;
230238
Daemon::from_environment()?.set_remote_control(mode).await

codex-rs/app-server-daemon/src/remote_control_client.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use codex_app_server_protocol::RemoteControlDisableParams;
1212
use codex_app_server_protocol::RemoteControlDisableResponse;
1313
use codex_app_server_protocol::RemoteControlEnableParams;
1414
use codex_app_server_protocol::RemoteControlEnableResponse;
15+
use codex_app_server_protocol::RemoteControlPairingStartParams;
16+
use codex_app_server_protocol::RemoteControlPairingStartResponse;
1517
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
1618
use codex_app_server_protocol::RequestId;
1719
use serde::de::DeserializeOwned;
@@ -53,6 +55,35 @@ pub(crate) async fn disable_remote_control(socket_path: &Path) -> Result<RemoteC
5355
Ok(RemoteControlReadyStatus::from(response))
5456
}
5557

58+
pub(crate) async fn start_pairing(socket_path: &Path) -> Result<RemoteControlPairingStartResponse> {
59+
let mut websocket = client::connect(socket_path).await?;
60+
initialize_client(&mut websocket).await?;
61+
let params = serde_json::to_value(RemoteControlPairingStartParams { manual_code: true })?;
62+
send_remote_control_request(
63+
&mut websocket,
64+
REMOTE_CONTROL_REQUEST_ID.clone(),
65+
"remoteControl/pairing/start",
66+
Some(params),
67+
)
68+
.await?;
69+
let response = match read_remote_control_response(
70+
&mut websocket,
71+
&REMOTE_CONTROL_REQUEST_ID,
72+
"remoteControl/pairing/start",
73+
)
74+
.await?
75+
{
76+
RemoteControlRpcResponse::Success(response) => response,
77+
RemoteControlRpcResponse::InvalidParams => {
78+
return Err(anyhow!(
79+
"remoteControl/pairing/start rejected manual pairing parameters"
80+
));
81+
}
82+
};
83+
websocket.close(None).await.ok();
84+
Ok(response)
85+
}
86+
5687
pub(crate) async fn enable_remote_control_with_connect_retry(
5788
socket_path: &Path,
5889
connect_timeout: Duration,
@@ -538,6 +569,53 @@ mod tests {
538569
Ok(())
539570
}
540571

572+
#[tokio::test]
573+
async fn start_pairing_requests_manual_code() -> Result<()> {
574+
let dir = TempDir::new()?;
575+
let socket_path = dir.path().join("app-server.sock");
576+
let listener = UnixListener::bind(&socket_path).await?;
577+
let server_task = tokio::spawn(async move {
578+
let mut websocket = accept_initialized_client(listener).await?;
579+
let pairing = client::read_message(&mut websocket).await?;
580+
let JSONRPCMessage::Request(pairing) = pairing else {
581+
panic!("expected remoteControl/pairing/start request");
582+
};
583+
assert_eq!(pairing.id, REMOTE_CONTROL_REQUEST_ID);
584+
assert_eq!(pairing.method, "remoteControl/pairing/start");
585+
assert_eq!(
586+
pairing.params,
587+
Some(serde_json::json!({ "manualCode": true }))
588+
);
589+
client::send_message(
590+
&mut websocket,
591+
&JSONRPCMessage::Response(JSONRPCResponse {
592+
id: REMOTE_CONTROL_REQUEST_ID,
593+
result: serde_json::to_value(RemoteControlPairingStartResponse {
594+
pairing_code: "pairing-code".to_string(),
595+
manual_pairing_code: Some("ABCD-EFGH".to_string()),
596+
environment_id: "env_test".to_string(),
597+
expires_at: 1_700_000_000,
598+
})?,
599+
}),
600+
)
601+
.await?;
602+
Ok::<_, anyhow::Error>(())
603+
});
604+
605+
let response = start_pairing(&socket_path).await?;
606+
server_task.await??;
607+
assert_eq!(
608+
response,
609+
RemoteControlPairingStartResponse {
610+
pairing_code: "pairing-code".to_string(),
611+
manual_pairing_code: Some("ABCD-EFGH".to_string()),
612+
environment_id: "env_test".to_string(),
613+
expires_at: 1_700_000_000,
614+
}
615+
);
616+
Ok(())
617+
}
618+
541619
struct EnableScenario {
542620
initial_notification: Option<RemoteControlStatusChangedNotification>,
543621
enable_response: RemoteControlStatusChangedNotification,

codex-rs/cli/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3559,6 +3559,15 @@ mod tests {
35593559
assert!(err.to_string().contains("remote-control"));
35603560
}
35613561

3562+
#[test]
3563+
fn remote_control_pair_parses() {
3564+
let cli = MultitoolCli::try_parse_from(["codex", "remote-control", "pair"]).expect("parse");
3565+
let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else {
3566+
panic!("expected remote-control subcommand");
3567+
};
3568+
assert_eq!(remote_control.subcommand_name(), "remote-control pair");
3569+
}
3570+
35623571
#[test]
35633572
fn remote_flag_parses_for_interactive_root() {
35643573
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"])

codex-rs/cli/src/remote_control_cmd.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use codex_app_server_daemon::RemoteControlReadyOutput as AppServerRemoteControlR
1313
use codex_app_server_daemon::RemoteControlReadyStatus as AppServerRemoteControlReadyStatus;
1414
use codex_app_server_daemon::RemoteControlStartOutput as AppServerRemoteControlStartOutput;
1515
use codex_app_server_protocol::RemoteControlConnectionStatus;
16+
use codex_app_server_protocol::RemoteControlPairingStartResponse;
1617
use codex_arg0::Arg0DispatchPaths;
1718
use codex_config::LoaderOverrides;
1819
use codex_protocol::protocol::SessionSource;
@@ -43,6 +44,7 @@ impl RemoteControlCommand {
4344
None => "remote-control",
4445
Some(RemoteControlSubcommand::Start) => "remote-control start",
4546
Some(RemoteControlSubcommand::Stop) => "remote-control stop",
47+
Some(RemoteControlSubcommand::Pair) => "remote-control pair",
4648
}
4749
}
4850
}
@@ -54,6 +56,9 @@ enum RemoteControlSubcommand {
5456

5557
/// Stop the app-server daemon.
5658
Stop,
59+
60+
/// Create and print a short-lived manual pairing code.
61+
Pair,
5762
}
5863

5964
pub(crate) async fn run(
@@ -82,6 +87,10 @@ pub(crate) async fn run(
8287
let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Stop).await?;
8388
print_remote_control_stop_output(&output, command.json)?;
8489
}
90+
Some(RemoteControlSubcommand::Pair) => {
91+
let output = codex_app_server_daemon::start_remote_control_pairing().await?;
92+
print_remote_control_pairing_output(&output, command.json)?;
93+
}
8594
}
8695
Ok(())
8796
}
@@ -451,6 +460,29 @@ fn print_remote_control_stop_output(
451460
Ok(())
452461
}
453462

463+
fn print_remote_control_pairing_output(
464+
output: &RemoteControlPairingStartResponse,
465+
json: bool,
466+
) -> anyhow::Result<()> {
467+
println!("{}", format_remote_control_pairing_output(output, json)?);
468+
Ok(())
469+
}
470+
471+
fn format_remote_control_pairing_output(
472+
output: &RemoteControlPairingStartResponse,
473+
json: bool,
474+
) -> anyhow::Result<String> {
475+
if json {
476+
return Ok(serde_json::to_string(output)?);
477+
}
478+
479+
let manual_pairing_code = output
480+
.manual_pairing_code
481+
.as_deref()
482+
.context("remote-control pairing response did not include a manual pairing code")?;
483+
Ok(format!("Pairing code: {manual_pairing_code}"))
484+
}
485+
454486
fn remote_control_stop_human_message(output: &AppServerLifecycleOutput) -> String {
455487
match output.status {
456488
AppServerLifecycleStatus::Stopped => "Remote control stopped.".to_string(),
@@ -509,6 +541,15 @@ mod tests {
509541
}
510542
}
511543

544+
fn pairing_response(manual_pairing_code: Option<&str>) -> RemoteControlPairingStartResponse {
545+
RemoteControlPairingStartResponse {
546+
pairing_code: "pairing-code".to_string(),
547+
manual_pairing_code: manual_pairing_code.map(str::to_string),
548+
environment_id: "env_test".to_string(),
549+
expires_at: 1_700_000_000,
550+
}
551+
}
552+
512553
#[test]
513554
fn remote_control_human_start_messages_use_server_name() {
514555
assert_eq!(
@@ -629,6 +670,41 @@ mod tests {
629670
);
630671
}
631672

673+
#[test]
674+
fn remote_control_pairing_human_output_labels_the_manual_code() {
675+
assert_eq!(
676+
format_remote_control_pairing_output(&pairing_response(Some("ABCD-EFGH")), false)
677+
.expect("manual pairing output"),
678+
"Pairing code: ABCD-EFGH"
679+
);
680+
}
681+
682+
#[test]
683+
fn remote_control_pairing_json_output_preserves_pairing_artifacts() {
684+
let output =
685+
format_remote_control_pairing_output(&pairing_response(Some("ABCD-EFGH")), true)
686+
.expect("pairing JSON output");
687+
assert_eq!(
688+
serde_json::from_str::<serde_json::Value>(&output).expect("valid JSON"),
689+
json!({
690+
"pairingCode": "pairing-code",
691+
"manualPairingCode": "ABCD-EFGH",
692+
"environmentId": "env_test",
693+
"expiresAt": 1_700_000_000,
694+
})
695+
);
696+
}
697+
698+
#[test]
699+
fn remote_control_pairing_human_output_requires_manual_code() {
700+
assert_eq!(
701+
format_remote_control_pairing_output(&pairing_response(None), false)
702+
.expect_err("missing manual pairing code should fail")
703+
.to_string(),
704+
"remote-control pairing response did not include a manual pairing code"
705+
);
706+
}
707+
632708
#[tokio::test]
633709
async fn foreground_wait_aborts_app_server_on_stop_signal() {
634710
let app_server_task = tokio::spawn(std::future::pending::<std::io::Result<()>>());

0 commit comments

Comments
 (0)