mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
35f5d02464
commit
f4e6aa70e5
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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<()>>());
|
||||
|
||||
Reference in New Issue
Block a user