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
Unverified
parent 35f5d02464
commit f4e6aa70e5
4 changed files with 171 additions and 0 deletions
+9
View File
@@ -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"])
+76
View File
@@ -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<String> {
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::<serde_json::Value>(&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::<std::io::Result<()>>());