mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
feat(app-server): add remote control pairing start
This commit is contained in:
@@ -838,6 +838,12 @@ client_request_definitions! {
|
||||
serialization: global_shared_read("remote-control"),
|
||||
response: v2::RemoteControlStatusReadResponse,
|
||||
},
|
||||
#[experimental("remoteControl/pairing/start")]
|
||||
RemoteControlPairingStart => "remoteControl/pairing/start" {
|
||||
params: v2::RemoteControlPairingStartParams,
|
||||
serialization: global_shared_read("remote-control"),
|
||||
response: v2::RemoteControlPairingStartResponse,
|
||||
},
|
||||
#[experimental("collaborationMode/list")]
|
||||
/// Lists collaboration mode presets.
|
||||
CollaborationModeList => "collaborationMode/list" {
|
||||
@@ -1961,6 +1967,17 @@ mod tests {
|
||||
},
|
||||
};
|
||||
assert_eq!(mcp_resource_read.serialization_scope(), None);
|
||||
|
||||
let remote_control_pairing_start = ClientRequest::RemoteControlPairingStart {
|
||||
request_id: request_id(),
|
||||
params: v2::RemoteControlPairingStartParams::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
remote_control_pairing_start.serialization_scope(),
|
||||
Some(ClientRequestSerializationScope::GlobalSharedRead(
|
||||
"remote-control"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,6 +44,25 @@ pub struct RemoteControlStatusReadResponse {
|
||||
pub environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RemoteControlPairingStartParams {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub manual_code: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RemoteControlPairingStartResponse {
|
||||
pub pairing_code: String,
|
||||
pub manual_pairing_code: Option<String>,
|
||||
pub environment_id: String,
|
||||
#[ts(type = "number")]
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
mod client_tracker;
|
||||
mod enroll;
|
||||
mod protocol;
|
||||
mod segment;
|
||||
mod server;
|
||||
mod websocket;
|
||||
|
||||
use crate::transport::remote_control::server::SharedRemoteControlServer;
|
||||
use crate::transport::remote_control::websocket::RemoteControlChannels;
|
||||
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
|
||||
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
|
||||
@@ -16,6 +17,8 @@ use super::CHANNEL_CAPACITY;
|
||||
use super::TransportEvent;
|
||||
use super::next_connection_id;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_login::AuthManager;
|
||||
use codex_state::StateRuntime;
|
||||
@@ -52,6 +55,7 @@ pub struct RemoteControlHandle {
|
||||
enabled_tx: Arc<watch::Sender<bool>>,
|
||||
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
|
||||
state_db_available: bool,
|
||||
server: SharedRemoteControlServer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -129,6 +133,16 @@ impl RemoteControlHandle {
|
||||
self.status_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn start_pairing(
|
||||
&self,
|
||||
params: RemoteControlPairingStartParams,
|
||||
) -> io::Result<RemoteControlPairingStartResponse> {
|
||||
if !*self.enabled_tx.borrow() {
|
||||
return Err(remote_control_pairing_disabled_error());
|
||||
}
|
||||
self.server.start_pairing(params).await
|
||||
}
|
||||
|
||||
fn publish_status(
|
||||
&self,
|
||||
connection_status: RemoteControlConnectionStatus,
|
||||
@@ -176,6 +190,20 @@ fn remote_control_status_with_connection_status(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remote_control_pairing_disabled_error() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"remote control pairing requires remote control to be enabled",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn remote_control_pairing_unavailable_error() -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"remote control pairing is unavailable until enrollment completes",
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn start_remote_control(
|
||||
config: RemoteControlStartConfig,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
@@ -213,6 +241,13 @@ pub async fn start_remote_control(
|
||||
};
|
||||
let (status_tx, _status_rx) = watch::channel(initial_status);
|
||||
let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone());
|
||||
let server = SharedRemoteControlServer::new(
|
||||
remote_control_url.clone(),
|
||||
installation_id.clone(),
|
||||
server_name.clone(),
|
||||
state_db,
|
||||
auth_manager.clone(),
|
||||
);
|
||||
info!(
|
||||
remote_control_url = %remote_control_url,
|
||||
installation_id = %installation_id,
|
||||
@@ -225,6 +260,7 @@ pub async fn start_remote_control(
|
||||
let installation_id_for_log = installation_id.clone();
|
||||
let server_name_for_log = server_name.clone();
|
||||
let shutdown_token_for_log = shutdown_token.clone();
|
||||
let server_for_task = server.clone();
|
||||
let join_handle = tokio::spawn(async move {
|
||||
info!(
|
||||
remote_control_url = %remote_control_url_for_log,
|
||||
@@ -240,12 +276,12 @@ pub async fn start_remote_control(
|
||||
remote_control_target,
|
||||
server_name,
|
||||
},
|
||||
state_db,
|
||||
auth_manager,
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
},
|
||||
server_for_task,
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
)
|
||||
@@ -289,6 +325,7 @@ pub async fn start_remote_control(
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available,
|
||||
server,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
use super::super::protocol::RemoteControlTarget;
|
||||
use super::super::protocol::StartRemoteControlPairingRequest;
|
||||
use super::super::protocol::normalize_remote_control_url;
|
||||
use super::super::server::RemoteControlServer;
|
||||
use super::super::server::SharedRemoteControlServer;
|
||||
use super::*;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_posts_server_token_and_maps_response() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let remote_control_target = remote_control_target_for_listener(&listener);
|
||||
assert_eq!(
|
||||
remote_control_target.pair_url,
|
||||
format!(
|
||||
"http://{}/backend-api/wham/remote/control/server/pair",
|
||||
listener.local_addr().expect("listener should have addr")
|
||||
)
|
||||
);
|
||||
let pairing_server = tokio::spawn(async move {
|
||||
let pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
pairing_request.headers.get("authorization"),
|
||||
Some(&"Bearer remote-control-token".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&pairing_request.body)
|
||||
.expect("pairing body should deserialize"),
|
||||
json!({ "manual_code": true })
|
||||
);
|
||||
respond_with_json(
|
||||
pairing_request.stream,
|
||||
json!({
|
||||
"pairing_code": "pairing-code",
|
||||
"manual_pairing_code": "ABCD-EFGH",
|
||||
"server_id": "server-id",
|
||||
"environment_id": "environment-id",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
});
|
||||
assert_eq!(
|
||||
remote_control_server_state(
|
||||
OffsetDateTime::from_unix_timestamp(33_336_362_096)
|
||||
.expect("future timestamp should parse"),
|
||||
)
|
||||
.start_pairing(
|
||||
&remote_control_target,
|
||||
StartRemoteControlPairingRequest { manual_code: true },
|
||||
)
|
||||
.await
|
||||
.expect("pairing should succeed"),
|
||||
RemoteControlPairingStartResponse {
|
||||
pairing_code: "pairing-code".to_string(),
|
||||
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
||||
environment_id: "environment-id".to_string(),
|
||||
expires_at: 33_336_362_096,
|
||||
}
|
||||
);
|
||||
pairing_server.await.expect("pairing server should join");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_preserves_backend_error_context() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let remote_control_target = remote_control_target_for_listener(&listener);
|
||||
let expected_pair_url = remote_control_target.pair_url.clone();
|
||||
let pairing_server = tokio::spawn(async move {
|
||||
let pairing_request = accept_http_request(&listener).await;
|
||||
respond_with_status_and_headers(
|
||||
pairing_request.stream,
|
||||
"503 Service Unavailable",
|
||||
&[("x-request-id", "request-123"), ("cf-ray", "ray-123")],
|
||||
"pairing unavailable",
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
remote_control_server_state(
|
||||
OffsetDateTime::from_unix_timestamp(33_336_362_096)
|
||||
.expect("future timestamp should parse"),
|
||||
)
|
||||
.start_pairing(
|
||||
&remote_control_target,
|
||||
StartRemoteControlPairingRequest { manual_code: false },
|
||||
)
|
||||
.await
|
||||
.expect_err("pairing should fail")
|
||||
.to_string(),
|
||||
format!(
|
||||
"remote control pairing failed at `{expected_pair_url}`: HTTP 503 Service Unavailable, request-id: request-123, cf-ray: ray-123, body: pairing unavailable"
|
||||
)
|
||||
);
|
||||
pairing_server.await.expect("pairing server should join");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_rejects_expired_server_token() {
|
||||
let err = remote_control_server_state(
|
||||
OffsetDateTime::from_unix_timestamp(0).expect("expired timestamp should parse"),
|
||||
)
|
||||
.start_pairing(
|
||||
&RemoteControlTarget {
|
||||
websocket_url: "ws://unused".to_string(),
|
||||
enroll_url: "http://unused".to_string(),
|
||||
refresh_url: "http://unused".to_string(),
|
||||
pair_url: "http://unused".to_string(),
|
||||
},
|
||||
StartRemoteControlPairingRequest { manual_code: false },
|
||||
)
|
||||
.await
|
||||
.expect_err("expired server token should fail pairing");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"remote control pairing is unavailable because the server token expired"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_refreshes_server_token_before_posting() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let server = shared_remote_control_server_for_listener(
|
||||
&listener,
|
||||
&codex_home,
|
||||
remote_control_auth_manager(),
|
||||
)
|
||||
.await;
|
||||
server
|
||||
.replace(Some(RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_test".to_string(),
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: "server-name".to_string(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
}))
|
||||
.await;
|
||||
|
||||
let pairing_task = tokio::spawn({
|
||||
let server = server.clone();
|
||||
async move {
|
||||
server
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.await
|
||||
}
|
||||
});
|
||||
let refresh_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
refresh_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
|
||||
);
|
||||
respond_with_json(
|
||||
refresh_request.stream,
|
||||
remote_control_server_token_response(
|
||||
"srv_e_test",
|
||||
"env_test",
|
||||
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
pairing_request.headers.get("authorization"),
|
||||
Some(&format!(
|
||||
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
|
||||
))
|
||||
);
|
||||
respond_with_json(
|
||||
pairing_request.stream,
|
||||
json!({
|
||||
"pairing_code": "pairing-code",
|
||||
"manual_pairing_code": null,
|
||||
"server_id": "srv_e_test",
|
||||
"environment_id": "env_test",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
pairing_task
|
||||
.await
|
||||
.expect("pairing task should join")
|
||||
.expect("pairing should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_does_not_require_websocket_connection() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let remote_control_url = remote_control_url_for_listener(&listener);
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let (transport_event_tx, _transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
RemoteControlStartConfig {
|
||||
remote_control_url,
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
},
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
remote_control_auth_manager(),
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
respond_with_json(
|
||||
enroll_request.stream,
|
||||
remote_control_server_token_response(
|
||||
"srv_e_initial",
|
||||
"env_initial",
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let stalled_websocket_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
stalled_websocket_request.request_line,
|
||||
"GET /backend-api/wham/remote/control/server HTTP/1.1"
|
||||
);
|
||||
let pairing_task = tokio::spawn({
|
||||
let remote_handle = remote_handle.clone();
|
||||
async move {
|
||||
remote_handle
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.await
|
||||
}
|
||||
});
|
||||
let stalled_pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
stalled_pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
remote_handle.status().status,
|
||||
RemoteControlConnectionStatus::Connecting
|
||||
);
|
||||
respond_with_json(
|
||||
stalled_pairing_request.stream,
|
||||
json!({
|
||||
"pairing_code": "pairing-code",
|
||||
"manual_pairing_code": null,
|
||||
"server_id": "srv_e_initial",
|
||||
"environment_id": "env_initial",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
pairing_task
|
||||
.await
|
||||
.expect("pairing task should join")
|
||||
.expect("pairing should succeed before websocket connects");
|
||||
|
||||
drop(stalled_websocket_request);
|
||||
shutdown_token.cancel();
|
||||
let _ = remote_task.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_auth_change_cancels_in_flight_pairing() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json(Some("account_id")),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("initial auth should save");
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await;
|
||||
let server =
|
||||
shared_remote_control_server_for_listener(&listener, &codex_home, auth_manager.clone())
|
||||
.await;
|
||||
let pairing_task = tokio::spawn({
|
||||
let server = server.clone();
|
||||
async move {
|
||||
server
|
||||
.start_pairing(RemoteControlPairingStartParams::default())
|
||||
.await
|
||||
}
|
||||
});
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
respond_with_json(
|
||||
enroll_request.stream,
|
||||
remote_control_server_token_response(
|
||||
"srv_e_initial",
|
||||
"env_initial",
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let stalled_pairing_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
stalled_pairing_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
|
||||
);
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json(Some("next_account_id")),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("next auth should save");
|
||||
auth_manager.reload().await;
|
||||
respond_with_json(
|
||||
stalled_pairing_request.stream,
|
||||
json!({
|
||||
"pairing_code": "stale-pairing-code",
|
||||
"manual_pairing_code": null,
|
||||
"server_id": "srv_e_initial",
|
||||
"environment_id": "env_initial",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
pairing_task
|
||||
.await
|
||||
.expect("pairing task should join")
|
||||
.expect_err("stale pairing should be discarded")
|
||||
.to_string(),
|
||||
"remote control pairing is unavailable until enrollment completes"
|
||||
);
|
||||
}
|
||||
|
||||
fn remote_control_target_for_listener(listener: &TcpListener) -> RemoteControlTarget {
|
||||
normalize_remote_control_url(&remote_control_url_for_listener(listener))
|
||||
.expect("target should parse")
|
||||
}
|
||||
|
||||
fn remote_control_server_state(expires_at: OffsetDateTime) -> RemoteControlServer {
|
||||
RemoteControlServer {
|
||||
account_id: "account-id".to_string(),
|
||||
environment_id: "environment-id".to_string(),
|
||||
server_id: "server-id".to_string(),
|
||||
server_name: "server-name".to_string(),
|
||||
remote_control_token: Some("remote-control-token".to_string()),
|
||||
expires_at: Some(expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
async fn shared_remote_control_server_for_listener(
|
||||
listener: &TcpListener,
|
||||
codex_home: &TempDir,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> SharedRemoteControlServer {
|
||||
SharedRemoteControlServer::new(
|
||||
remote_control_url_for_listener(listener),
|
||||
TEST_INSTALLATION_ID.to_string(),
|
||||
"server-name".to_string(),
|
||||
Some(remote_control_state_runtime(codex_home).await),
|
||||
auth_manager,
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub(super) struct RemoteControlTarget {
|
||||
pub(super) websocket_url: String,
|
||||
pub(super) enroll_url: String,
|
||||
pub(super) refresh_url: String,
|
||||
pub(super) pair_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -37,6 +38,21 @@ pub(super) struct RefreshRemoteServerRequest {
|
||||
pub(super) installation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct StartRemoteControlPairingRequest {
|
||||
pub(super) manual_code: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct StartRemoteControlPairingResponse {
|
||||
pub(super) pairing_code: String,
|
||||
pub(super) manual_pairing_code: Option<String>,
|
||||
#[serde(rename = "server_id")]
|
||||
pub(super) _server_id: String,
|
||||
pub(super) environment_id: String,
|
||||
pub(super) expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ClientId(pub String);
|
||||
@@ -189,6 +205,9 @@ pub(super) fn normalize_remote_control_url(
|
||||
let refresh_url = remote_control_url
|
||||
.join("wham/remote/control/server/refresh")
|
||||
.map_err(map_url_parse_error)?;
|
||||
let pair_url = remote_control_url
|
||||
.join("wham/remote/control/server/pair")
|
||||
.map_err(map_url_parse_error)?;
|
||||
let mut websocket_url = remote_control_url
|
||||
.join("wham/remote/control/server")
|
||||
.map_err(map_url_parse_error)?;
|
||||
@@ -207,6 +226,7 @@ pub(super) fn normalize_remote_control_url(
|
||||
websocket_url: websocket_url.to_string(),
|
||||
enroll_url: enroll_url.to_string(),
|
||||
refresh_url: refresh_url.to_string(),
|
||||
pair_url: pair_url.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,6 +247,8 @@ mod tests {
|
||||
.to_string(),
|
||||
refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh"
|
||||
.to_string(),
|
||||
pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair"
|
||||
.to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -242,6 +264,9 @@ mod tests {
|
||||
refresh_url:
|
||||
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh"
|
||||
.to_string(),
|
||||
pair_url:
|
||||
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair"
|
||||
.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -258,6 +283,8 @@ mod tests {
|
||||
.to_string(),
|
||||
refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh"
|
||||
.to_string(),
|
||||
pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair"
|
||||
.to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -271,6 +298,8 @@ mod tests {
|
||||
refresh_url:
|
||||
"https://localhost:8443/backend-api/wham/remote/control/server/refresh"
|
||||
.to_string(),
|
||||
pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair"
|
||||
.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+565
-93
@@ -2,8 +2,13 @@ use super::protocol::EnrollRemoteServerRequest;
|
||||
use super::protocol::EnrollRemoteServerResponse;
|
||||
use super::protocol::RefreshRemoteServerRequest;
|
||||
use super::protocol::RemoteControlTarget;
|
||||
use super::protocol::StartRemoteControlPairingRequest;
|
||||
use super::protocol::StartRemoteControlPairingResponse;
|
||||
use super::remote_control_pairing_unavailable_error;
|
||||
use axum::http::HeaderMap;
|
||||
use codex_api::SharedAuthProvider;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_state::RemoteControlEnrollmentRecord;
|
||||
use codex_state::StateRuntime;
|
||||
@@ -11,12 +16,17 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::OwnedSemaphorePermit;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096;
|
||||
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30;
|
||||
|
||||
@@ -27,7 +37,7 @@ pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
|
||||
pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct RemoteControlEnrollment {
|
||||
pub(super) struct RemoteControlServer {
|
||||
pub(super) account_id: String,
|
||||
pub(super) environment_id: String,
|
||||
pub(super) server_id: String,
|
||||
@@ -36,7 +46,7 @@ pub(super) struct RemoteControlEnrollment {
|
||||
pub(super) expires_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
impl RemoteControlEnrollment {
|
||||
impl RemoteControlServer {
|
||||
pub(super) fn should_refresh_server_token(&self) -> bool {
|
||||
self.remote_control_token.is_none()
|
||||
|| self.expires_at.is_none_or(|expires_at| {
|
||||
@@ -62,7 +72,7 @@ pub(super) async fn load_persisted_remote_control_enrollment(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<Option<RemoteControlEnrollment>> {
|
||||
) -> io::Result<Option<RemoteControlServer>> {
|
||||
let Some(state_db) = state_db else {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
@@ -100,7 +110,7 @@ pub(super) async fn load_persisted_remote_control_enrollment(
|
||||
enrollment.server_id,
|
||||
enrollment.environment_id
|
||||
);
|
||||
Ok(Some(RemoteControlEnrollment {
|
||||
Ok(Some(RemoteControlServer {
|
||||
account_id: enrollment.account_id,
|
||||
environment_id: enrollment.environment_id,
|
||||
server_id: enrollment.server_id,
|
||||
@@ -124,7 +134,7 @@ pub(super) async fn update_persisted_remote_control_enrollment(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: Option<&RemoteControlEnrollment>,
|
||||
enrollment: Option<&RemoteControlServer>,
|
||||
) -> io::Result<()> {
|
||||
let Some(state_db) = state_db else {
|
||||
return Err(io::Error::new(
|
||||
@@ -211,8 +221,14 @@ fn redact_remote_control_response_body(body: &str) -> String {
|
||||
let Some(body_object) = body_json.as_object_mut() else {
|
||||
return body.to_string();
|
||||
};
|
||||
if let Some(remote_control_token) = body_object.get_mut("remote_control_token") {
|
||||
*remote_control_token = serde_json::Value::String("<redacted>".to_string());
|
||||
for sensitive_field in [
|
||||
"remote_control_token",
|
||||
"pairing_code",
|
||||
"manual_pairing_code",
|
||||
] {
|
||||
if let Some(value) = body_object.get_mut(sensitive_field) {
|
||||
*value = serde_json::Value::String("<redacted>".to_string());
|
||||
}
|
||||
}
|
||||
body_json.to_string()
|
||||
}
|
||||
@@ -230,84 +246,526 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String {
|
||||
format!("request-id: {request_id_str}, cf-ray: {cf_ray_str}")
|
||||
}
|
||||
|
||||
pub(super) async fn enroll_remote_control_server(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
) -> io::Result<RemoteControlEnrollment> {
|
||||
let enroll_url = &remote_control_target.enroll_url;
|
||||
let request = EnrollRemoteServerRequest {
|
||||
name: server_name.to_string(),
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
app_server_version: env!("CARGO_PKG_VERSION"),
|
||||
installation_id: installation_id.to_string(),
|
||||
};
|
||||
let enrollment_response = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
|
||||
enroll_url,
|
||||
auth,
|
||||
installation_id,
|
||||
&request,
|
||||
"enroll",
|
||||
"server enrollment",
|
||||
)
|
||||
.await?;
|
||||
let mut enrollment = RemoteControlEnrollment {
|
||||
account_id: auth.account_id.clone(),
|
||||
environment_id: enrollment_response.environment_id,
|
||||
server_id: enrollment_response.server_id,
|
||||
server_name: server_name.to_string(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
update_remote_control_server_token(
|
||||
&mut enrollment,
|
||||
enroll_url,
|
||||
enrollment_response.remote_control_token,
|
||||
enrollment_response.expires_at,
|
||||
)?;
|
||||
Ok(enrollment)
|
||||
}
|
||||
|
||||
pub(super) async fn refresh_remote_control_server(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
enrollment: &mut RemoteControlEnrollment,
|
||||
) -> io::Result<()> {
|
||||
let refresh_url = &remote_control_target.refresh_url;
|
||||
let request = RefreshRemoteServerRequest {
|
||||
server_id: enrollment.server_id.clone(),
|
||||
installation_id: installation_id.to_string(),
|
||||
};
|
||||
let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
|
||||
refresh_url,
|
||||
auth,
|
||||
installation_id,
|
||||
&request,
|
||||
"refresh",
|
||||
"server refresh",
|
||||
)
|
||||
.await?;
|
||||
if refreshed.server_id != enrollment.server_id
|
||||
|| refreshed.environment_id != enrollment.environment_id
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
|
||||
enrollment.server_id,
|
||||
enrollment.environment_id,
|
||||
refreshed.server_id,
|
||||
refreshed.environment_id
|
||||
)));
|
||||
impl RemoteControlServer {
|
||||
pub(super) async fn enroll(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
) -> io::Result<Self> {
|
||||
let enroll_url = &remote_control_target.enroll_url;
|
||||
let request = EnrollRemoteServerRequest {
|
||||
name: server_name.to_string(),
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
app_server_version: env!("CARGO_PKG_VERSION"),
|
||||
installation_id: installation_id.to_string(),
|
||||
};
|
||||
let enrollment_response =
|
||||
send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
|
||||
enroll_url,
|
||||
auth,
|
||||
installation_id,
|
||||
&request,
|
||||
"enroll",
|
||||
"server enrollment",
|
||||
)
|
||||
.await?;
|
||||
let mut server = Self {
|
||||
account_id: auth.account_id.clone(),
|
||||
environment_id: enrollment_response.environment_id,
|
||||
server_id: enrollment_response.server_id,
|
||||
server_name: server_name.to_string(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
update_remote_control_server_token(
|
||||
&mut server,
|
||||
enroll_url,
|
||||
enrollment_response.remote_control_token,
|
||||
enrollment_response.expires_at,
|
||||
)?;
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
update_remote_control_server_token(
|
||||
enrollment,
|
||||
refresh_url,
|
||||
refreshed.remote_control_token,
|
||||
refreshed.expires_at,
|
||||
)
|
||||
pub(super) async fn refresh_server_token(
|
||||
&mut self,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
) -> io::Result<()> {
|
||||
let refresh_url = &remote_control_target.refresh_url;
|
||||
let request = RefreshRemoteServerRequest {
|
||||
server_id: self.server_id.clone(),
|
||||
installation_id: installation_id.to_string(),
|
||||
};
|
||||
let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
|
||||
refresh_url,
|
||||
auth,
|
||||
installation_id,
|
||||
&request,
|
||||
"refresh",
|
||||
"server refresh",
|
||||
)
|
||||
.await?;
|
||||
if refreshed.server_id != self.server_id || refreshed.environment_id != self.environment_id
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
|
||||
self.server_id, self.environment_id, refreshed.server_id, refreshed.environment_id
|
||||
)));
|
||||
}
|
||||
|
||||
update_remote_control_server_token(
|
||||
self,
|
||||
refresh_url,
|
||||
refreshed.remote_control_token,
|
||||
refreshed.expires_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn start_pairing(
|
||||
&self,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
request: StartRemoteControlPairingRequest,
|
||||
) -> io::Result<RemoteControlPairingStartResponse> {
|
||||
let remote_control_token = self
|
||||
.remote_control_token
|
||||
.as_deref()
|
||||
.ok_or_else(remote_control_pairing_unavailable_error)?;
|
||||
let expires_at = self
|
||||
.expires_at
|
||||
.ok_or_else(remote_control_pairing_unavailable_error)?;
|
||||
if expires_at <= OffsetDateTime::now_utc() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"remote control pairing is unavailable because the server token expired",
|
||||
));
|
||||
}
|
||||
|
||||
let pairing_url = &remote_control_target.pair_url;
|
||||
let response = build_reqwest_client()
|
||||
.post(pairing_url)
|
||||
.timeout(REMOTE_CONTROL_PAIRING_TIMEOUT)
|
||||
.bearer_auth(remote_control_token)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to start remote control pairing at `{pairing_url}`: {err}"
|
||||
))
|
||||
})?;
|
||||
let headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to read remote control pairing response from `{pairing_url}`: {err}"
|
||||
))
|
||||
})?;
|
||||
let body_preview = preview_remote_control_response_body(&body);
|
||||
if !status.is_success() {
|
||||
let error_kind = match status.as_u16() {
|
||||
401 | 403 => ErrorKind::PermissionDenied,
|
||||
404 => ErrorKind::NotFound,
|
||||
_ => ErrorKind::Other,
|
||||
};
|
||||
return Err(io::Error::new(
|
||||
error_kind,
|
||||
format!(
|
||||
"remote control pairing failed at `{pairing_url}`: HTTP {status}, {}, body: {body_preview}",
|
||||
format_headers(&headers)
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let pairing =
|
||||
serde_json::from_slice::<StartRemoteControlPairingResponse>(&body).map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to parse remote control pairing response from `{pairing_url}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
|
||||
format_headers(&headers)
|
||||
))
|
||||
})?;
|
||||
let StartRemoteControlPairingResponse {
|
||||
pairing_code,
|
||||
manual_pairing_code,
|
||||
_server_id: _,
|
||||
environment_id,
|
||||
expires_at,
|
||||
} = pairing;
|
||||
let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339)
|
||||
.map_err(|err| {
|
||||
io::Error::new(
|
||||
ErrorKind::InvalidData,
|
||||
format!("invalid remote control pairing expires_at: {err}"),
|
||||
)
|
||||
})?
|
||||
.unix_timestamp();
|
||||
|
||||
Ok(RemoteControlPairingStartResponse {
|
||||
pairing_code,
|
||||
manual_pairing_code,
|
||||
environment_id,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct SharedRemoteControlServer {
|
||||
remote_control_url: String,
|
||||
installation_id: String,
|
||||
server_name: String,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
app_server_client_name: Arc<std::sync::Mutex<Option<String>>>,
|
||||
server: Arc<Mutex<Option<RemoteControlServer>>>,
|
||||
server_mutations: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl SharedRemoteControlServer {
|
||||
pub(super) fn new(
|
||||
remote_control_url: String,
|
||||
installation_id: String,
|
||||
server_name: String,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> Self {
|
||||
Self {
|
||||
remote_control_url,
|
||||
installation_id,
|
||||
server_name,
|
||||
state_db,
|
||||
auth_manager,
|
||||
app_server_client_name: Arc::new(std::sync::Mutex::new(None)),
|
||||
server: Arc::new(Mutex::new(None)),
|
||||
server_mutations: Arc::new(Semaphore::new(1)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_app_server_client_name(&self, app_server_client_name: Option<String>) {
|
||||
*self
|
||||
.app_server_client_name
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = app_server_client_name;
|
||||
}
|
||||
|
||||
pub(super) async fn snapshot(&self) -> Option<RemoteControlServer> {
|
||||
self.server.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(super) async fn clear(&self) {
|
||||
let Ok(_server_mutation) = self.server_mutation().await else {
|
||||
return;
|
||||
};
|
||||
*self.server.lock().await = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn replace(&self, server: Option<RemoteControlServer>) {
|
||||
let Ok(_server_mutation) = self.server_mutation().await else {
|
||||
return;
|
||||
};
|
||||
*self.server.lock().await = server;
|
||||
}
|
||||
|
||||
pub(super) async fn prepare(
|
||||
&self,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
) -> io::Result<(RemoteControlTarget, RemoteControlServer)> {
|
||||
self.prepare_with_environment_updates(auth, |_| {}).await
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_with_environment_updates(
|
||||
&self,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
mut publish_environment_id: impl FnMut(Option<String>),
|
||||
) -> io::Result<(RemoteControlTarget, RemoteControlServer)> {
|
||||
let remote_control_target =
|
||||
super::protocol::normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let app_server_client_name = self
|
||||
.app_server_client_name
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
let _server_mutation = self.server_mutation().await?;
|
||||
let Some(state_db) = self.state_db.as_deref() else {
|
||||
*self.server.lock().await = None;
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"remote control requires sqlite state db",
|
||||
));
|
||||
};
|
||||
let server = self.snapshot().await;
|
||||
let server = prepare_remote_control_server(
|
||||
&RemoteControlServerPrepareContext {
|
||||
state_db,
|
||||
remote_control_target: &remote_control_target,
|
||||
auth,
|
||||
installation_id: &self.installation_id,
|
||||
server_name: &self.server_name,
|
||||
app_server_client_name: app_server_client_name.as_deref(),
|
||||
},
|
||||
server,
|
||||
&mut publish_environment_id,
|
||||
)
|
||||
.await?;
|
||||
*self.server.lock().await = server.clone();
|
||||
Ok((
|
||||
remote_control_target,
|
||||
server
|
||||
.ok_or_else(|| io::Error::other("missing remote control server after prepare"))?,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn start_pairing(
|
||||
&self,
|
||||
params: codex_app_server_protocol::RemoteControlPairingStartParams,
|
||||
) -> io::Result<RemoteControlPairingStartResponse> {
|
||||
let auth_change_rx = self.auth_manager.auth_change_receiver();
|
||||
let auth_change_revision = *auth_change_rx.borrow();
|
||||
let auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if matches!(
|
||||
err.kind(),
|
||||
ErrorKind::PermissionDenied | ErrorKind::WouldBlock
|
||||
) {
|
||||
super::remote_control_pairing_unavailable_error()
|
||||
} else {
|
||||
err
|
||||
}
|
||||
})?;
|
||||
let (remote_control_target, server) = self.prepare(&auth).await?;
|
||||
let pairing_response = server
|
||||
.start_pairing(
|
||||
&remote_control_target,
|
||||
StartRemoteControlPairingRequest {
|
||||
manual_code: params.manual_code,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if *auth_change_rx.borrow() != auth_change_revision {
|
||||
return Err(super::remote_control_pairing_unavailable_error());
|
||||
}
|
||||
if pairing_response
|
||||
.as_ref()
|
||||
.is_err_and(|err| err.kind() == ErrorKind::PermissionDenied)
|
||||
{
|
||||
self.clear_server_token_if_current(&server).await;
|
||||
}
|
||||
if pairing_response
|
||||
.as_ref()
|
||||
.is_err_and(|err| err.kind() == ErrorKind::NotFound)
|
||||
{
|
||||
self.clear_server_if_current(&remote_control_target, &server)
|
||||
.await;
|
||||
}
|
||||
pairing_response
|
||||
}
|
||||
|
||||
pub(super) async fn clear_server_token_if_current(
|
||||
&self,
|
||||
expected_server: &RemoteControlServer,
|
||||
) {
|
||||
let Ok(_server_mutation) = self.server_mutation().await else {
|
||||
return;
|
||||
};
|
||||
let mut server = self.server.lock().await;
|
||||
if server.as_ref() != Some(expected_server) {
|
||||
return;
|
||||
}
|
||||
if let Some(server) = server.as_mut() {
|
||||
server.clear_server_token();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn clear_server_if_current(
|
||||
&self,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
expected_server: &RemoteControlServer,
|
||||
) {
|
||||
let app_server_client_name = self
|
||||
.app_server_client_name
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
let Ok(_server_mutation) = self.server_mutation().await else {
|
||||
return;
|
||||
};
|
||||
if self.snapshot().await.as_ref() != Some(expected_server) {
|
||||
return;
|
||||
}
|
||||
if update_persisted_remote_control_enrollment(
|
||||
self.state_db.as_deref(),
|
||||
remote_control_target,
|
||||
&expected_server.account_id,
|
||||
app_server_client_name.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
*self.server.lock().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
async fn server_mutation(&self) -> io::Result<OwnedSemaphorePermit> {
|
||||
self.server_mutations
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| io::Error::other("remote control server mutation semaphore closed"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn load_remote_control_auth(
|
||||
auth_manager: &AuthManager,
|
||||
) -> io::Result<RemoteControlConnectionAuth> {
|
||||
let mut reloaded = false;
|
||||
let auth = loop {
|
||||
let Some(auth) = auth_manager.auth().await else {
|
||||
if reloaded {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
"remote control requires ChatGPT authentication",
|
||||
));
|
||||
}
|
||||
auth_manager.reload().await;
|
||||
reloaded = true;
|
||||
continue;
|
||||
};
|
||||
if !auth.uses_codex_backend() {
|
||||
break auth;
|
||||
}
|
||||
if auth.get_account_id().is_none() && !reloaded {
|
||||
auth_manager.reload().await;
|
||||
reloaded = true;
|
||||
continue;
|
||||
}
|
||||
break auth;
|
||||
};
|
||||
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
"remote control requires ChatGPT authentication; API key auth is not supported",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(RemoteControlConnectionAuth {
|
||||
auth_provider: codex_model_provider::auth_provider_from_auth(&auth),
|
||||
account_id: auth.get_account_id().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
ErrorKind::WouldBlock,
|
||||
"remote control enrollment is waiting for a ChatGPT account id",
|
||||
)
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
struct RemoteControlServerPrepareContext<'a> {
|
||||
state_db: &'a StateRuntime,
|
||||
remote_control_target: &'a RemoteControlTarget,
|
||||
auth: &'a RemoteControlConnectionAuth,
|
||||
installation_id: &'a str,
|
||||
server_name: &'a str,
|
||||
app_server_client_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
async fn prepare_remote_control_server(
|
||||
context: &RemoteControlServerPrepareContext<'_>,
|
||||
mut server: Option<RemoteControlServer>,
|
||||
publish_environment_id: &mut impl FnMut(Option<String>),
|
||||
) -> io::Result<Option<RemoteControlServer>> {
|
||||
if server
|
||||
.as_ref()
|
||||
.is_some_and(|server| server.account_id != context.auth.account_id)
|
||||
{
|
||||
server = None;
|
||||
publish_environment_id(None);
|
||||
}
|
||||
if server.is_none() {
|
||||
server = load_persisted_remote_control_enrollment(
|
||||
Some(context.state_db),
|
||||
context.remote_control_target,
|
||||
&context.auth.account_id,
|
||||
context.app_server_client_name,
|
||||
)
|
||||
.await?
|
||||
.map(|mut server| {
|
||||
server.server_name = context.server_name.to_string();
|
||||
server
|
||||
});
|
||||
}
|
||||
if let Some(server) = server.as_ref() {
|
||||
publish_environment_id(Some(server.environment_id.clone()));
|
||||
}
|
||||
if server.is_none() {
|
||||
let new_server = RemoteControlServer::enroll(
|
||||
context.remote_control_target,
|
||||
context.auth,
|
||||
context.installation_id,
|
||||
context.server_name,
|
||||
)
|
||||
.await?;
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(context.state_db),
|
||||
context.remote_control_target,
|
||||
&context.auth.account_id,
|
||||
context.app_server_client_name,
|
||||
Some(&new_server),
|
||||
)
|
||||
.await?;
|
||||
publish_environment_id(Some(new_server.environment_id.clone()));
|
||||
server = Some(new_server);
|
||||
}
|
||||
if server
|
||||
.as_ref()
|
||||
.is_some_and(RemoteControlServer::should_refresh_server_token)
|
||||
{
|
||||
let refresh_result = server
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("missing remote control server before refresh"))?
|
||||
.refresh_server_token(
|
||||
context.remote_control_target,
|
||||
context.auth,
|
||||
context.installation_id,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = refresh_result {
|
||||
if err.kind() != ErrorKind::NotFound {
|
||||
return Err(err);
|
||||
}
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(context.state_db),
|
||||
context.remote_control_target,
|
||||
&context.auth.account_id,
|
||||
context.app_server_client_name,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
publish_environment_id(None);
|
||||
let new_server = RemoteControlServer::enroll(
|
||||
context.remote_control_target,
|
||||
context.auth,
|
||||
context.installation_id,
|
||||
context.server_name,
|
||||
)
|
||||
.await?;
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(context.state_db),
|
||||
context.remote_control_target,
|
||||
&context.auth.account_id,
|
||||
context.app_server_client_name,
|
||||
Some(&new_server),
|
||||
)
|
||||
.await?;
|
||||
publish_environment_id(Some(new_server.environment_id.clone()));
|
||||
server = Some(new_server);
|
||||
}
|
||||
}
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
async fn send_remote_control_server_request<Request, Response>(
|
||||
@@ -371,7 +829,7 @@ where
|
||||
}
|
||||
|
||||
fn update_remote_control_server_token(
|
||||
enrollment: &mut RemoteControlEnrollment,
|
||||
server: &mut RemoteControlServer,
|
||||
url: &str,
|
||||
token: String,
|
||||
expires_at: String,
|
||||
@@ -381,8 +839,8 @@ fn update_remote_control_server_token(
|
||||
"failed to parse remote control server token expiry from `{url}`: {err}"
|
||||
))
|
||||
})?;
|
||||
enrollment.remote_control_token = Some(token);
|
||||
enrollment.expires_at = Some(expires_at);
|
||||
server.remote_control_token = Some(token);
|
||||
server.expires_at = Some(expires_at);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -411,7 +869,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn remote_control_enrollment_refreshes_server_token_before_expiry() {
|
||||
let expires_soon = RemoteControlEnrollment {
|
||||
let expires_soon = RemoteControlServer {
|
||||
account_id: "account-a".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_id: "srv_e_first".to_string(),
|
||||
@@ -419,7 +877,7 @@ mod tests {
|
||||
remote_control_token: Some("expires-soon".to_string()),
|
||||
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)),
|
||||
};
|
||||
let expires_later = RemoteControlEnrollment {
|
||||
let expires_later = RemoteControlServer {
|
||||
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(31)),
|
||||
remote_control_token: Some("expires-later".to_string()),
|
||||
..expires_soon.clone()
|
||||
@@ -443,6 +901,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_remote_control_response_body_redacts_pairing_codes() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&preview_remote_control_response_body(
|
||||
br#"{"pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH"}"#
|
||||
))
|
||||
.expect("redacted response preview should stay valid json"),
|
||||
json!({
|
||||
"pairing_code": "<redacted>",
|
||||
"manual_pairing_code": "<redacted>",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() {
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
@@ -452,7 +924,7 @@ mod tests {
|
||||
let second_target =
|
||||
normalize_remote_control_url("https://api.chatgpt-staging.com/other/control")
|
||||
.expect("second target should parse");
|
||||
let first_enrollment = RemoteControlEnrollment {
|
||||
let first_enrollment = RemoteControlServer {
|
||||
account_id: "account-a".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_id: "srv_e_first".to_string(),
|
||||
@@ -460,7 +932,7 @@ mod tests {
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
let second_enrollment = RemoteControlEnrollment {
|
||||
let second_enrollment = RemoteControlServer {
|
||||
account_id: "account-a".to_string(),
|
||||
environment_id: "env_second".to_string(),
|
||||
server_id: "srv_e_second".to_string(),
|
||||
@@ -532,7 +1004,7 @@ mod tests {
|
||||
let second_target =
|
||||
normalize_remote_control_url("https://api.chatgpt-staging.com/other/control")
|
||||
.expect("second target should parse");
|
||||
let first_enrollment = RemoteControlEnrollment {
|
||||
let first_enrollment = RemoteControlServer {
|
||||
account_id: "account-a".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_id: "srv_e_first".to_string(),
|
||||
@@ -540,7 +1012,7 @@ mod tests {
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
let second_enrollment = RemoteControlEnrollment {
|
||||
let second_enrollment = RemoteControlServer {
|
||||
account_id: "account-a".to_string(),
|
||||
environment_id: "env_second".to_string(),
|
||||
server_id: "srv_e_second".to_string(),
|
||||
@@ -627,7 +1099,7 @@ mod tests {
|
||||
respond_with_json(stream, response_body).await;
|
||||
});
|
||||
|
||||
let err = enroll_remote_control_server(
|
||||
let err = RemoteControlServer::enroll(
|
||||
&remote_control_target,
|
||||
&RemoteControlConnectionAuth {
|
||||
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
|
||||
use super::enroll::REMOTE_CONTROL_INSTALLATION_ID_HEADER;
|
||||
use super::enroll::RemoteControlEnrollment;
|
||||
use super::enroll::load_persisted_remote_control_enrollment;
|
||||
use super::enroll::update_persisted_remote_control_enrollment;
|
||||
use super::protocol::ClientEnvelope;
|
||||
use super::protocol::ClientEvent;
|
||||
use super::protocol::ClientId;
|
||||
use super::protocol::StreamId;
|
||||
use super::protocol::normalize_remote_control_url;
|
||||
use super::server::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
|
||||
use super::server::REMOTE_CONTROL_INSTALLATION_ID_HEADER;
|
||||
use super::server::RemoteControlServer;
|
||||
use super::server::load_persisted_remote_control_enrollment;
|
||||
use super::server::update_persisted_remote_control_enrollment;
|
||||
use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION;
|
||||
use super::*;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
@@ -20,6 +20,7 @@ use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
@@ -895,6 +896,9 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
|
||||
let _ = remote_task.await;
|
||||
}
|
||||
|
||||
#[path = "pairing_integration_tests.rs"]
|
||||
mod pairing_integration_tests;
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -1312,7 +1316,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let remote_control_target =
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let persisted_enrollment = RemoteControlEnrollment {
|
||||
let persisted_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_persisted".to_string(),
|
||||
server_id: "srv_e_persisted".to_string(),
|
||||
@@ -1417,7 +1421,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
|
||||
let remote_control_target =
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let app_server_client_name = "stdio-client";
|
||||
let persisted_enrollment = RemoteControlEnrollment {
|
||||
let persisted_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_persisted".to_string(),
|
||||
server_id: "srv_e_persisted".to_string(),
|
||||
@@ -1505,7 +1509,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
|
||||
)
|
||||
.await;
|
||||
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
|
||||
let expected_enrollment = RemoteControlEnrollment {
|
||||
let expected_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_ready".to_string(),
|
||||
server_id: "srv_e_ready".to_string(),
|
||||
@@ -1582,7 +1586,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
let remote_control_target =
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
|
||||
let stale_enrollment = RemoteControlEnrollment {
|
||||
let stale_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_stale".to_string(),
|
||||
server_id: "srv_e_stale".to_string(),
|
||||
@@ -1590,7 +1594,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
let refreshed_enrollment = RemoteControlEnrollment {
|
||||
let refreshed_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_refreshed".to_string(),
|
||||
server_id: "srv_e_refreshed".to_string(),
|
||||
@@ -1699,7 +1703,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
let remote_control_target =
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
|
||||
let stale_enrollment = RemoteControlEnrollment {
|
||||
let stale_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_stale".to_string(),
|
||||
server_id: "srv_e_stale".to_string(),
|
||||
@@ -1707,7 +1711,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
let refreshed_enrollment = RemoteControlEnrollment {
|
||||
let refreshed_enrollment = RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_refreshed".to_string(),
|
||||
server_id: "srv_e_refreshed".to_string(),
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use crate::transport::TransportEvent;
|
||||
use crate::transport::remote_control::client_tracker::ClientTracker;
|
||||
use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL;
|
||||
use crate::transport::remote_control::enroll::RemoteControlConnectionAuth;
|
||||
use crate::transport::remote_control::enroll::RemoteControlEnrollment;
|
||||
use crate::transport::remote_control::enroll::enroll_remote_control_server;
|
||||
use crate::transport::remote_control::enroll::format_headers;
|
||||
use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment;
|
||||
use crate::transport::remote_control::enroll::preview_remote_control_response_body;
|
||||
use crate::transport::remote_control::enroll::refresh_remote_control_server;
|
||||
use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment;
|
||||
use crate::transport::remote_control::server::RemoteControlServer;
|
||||
use crate::transport::remote_control::server::SharedRemoteControlServer;
|
||||
use crate::transport::remote_control::server::format_headers;
|
||||
use crate::transport::remote_control::server::load_remote_control_auth;
|
||||
use crate::transport::remote_control::server::preview_remote_control_response_body;
|
||||
|
||||
use super::protocol::ClientEnvelope;
|
||||
use super::protocol::ClientEvent;
|
||||
@@ -28,7 +25,6 @@ use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_core::util::backoff;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_state::StateRuntime;
|
||||
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
@@ -243,12 +239,11 @@ pub(crate) struct RemoteControlWebsocket {
|
||||
installation_id: String,
|
||||
server_name: String,
|
||||
remote_control_target: Option<RemoteControlTarget>,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
status_publisher: RemoteControlStatusPublisher,
|
||||
shutdown_token: CancellationToken,
|
||||
reconnect_attempt: u64,
|
||||
enrollment: Option<RemoteControlEnrollment>,
|
||||
server: SharedRemoteControlServer,
|
||||
auth_recovery: UnauthorizedRecovery,
|
||||
auth_change_rx: watch::Receiver<u64>,
|
||||
client_tracker: Arc<Mutex<ClientTracker>>,
|
||||
@@ -282,6 +277,8 @@ enum ConnectionEndReason {
|
||||
Shutdown,
|
||||
Disabled,
|
||||
EnabledWatchClosed,
|
||||
AuthChanged,
|
||||
AuthWatchClosed,
|
||||
ConnectionWorkerStopped,
|
||||
}
|
||||
|
||||
@@ -366,17 +363,15 @@ impl RemoteControlStatusPublisher {
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct RemoteControlConnectOptions<'a> {
|
||||
installation_id: &'a str,
|
||||
server_name: &'a str,
|
||||
subscribe_cursor: Option<&'a str>,
|
||||
app_server_client_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl RemoteControlWebsocket {
|
||||
pub(crate) fn new(
|
||||
config: RemoteControlWebsocketConfig,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
channels: RemoteControlChannels,
|
||||
server: SharedRemoteControlServer,
|
||||
shutdown_token: CancellationToken,
|
||||
enabled_rx: watch::Receiver<bool>,
|
||||
) -> Self {
|
||||
@@ -396,12 +391,11 @@ impl RemoteControlWebsocket {
|
||||
installation_id: config.installation_id,
|
||||
server_name: config.server_name,
|
||||
remote_control_target: config.remote_control_target,
|
||||
state_db,
|
||||
auth_manager,
|
||||
status_publisher: channels.status_publisher,
|
||||
shutdown_token,
|
||||
reconnect_attempt: 0,
|
||||
enrollment: None,
|
||||
server,
|
||||
auth_recovery,
|
||||
auth_change_rx,
|
||||
client_tracker: Arc::new(Mutex::new(client_tracker)),
|
||||
@@ -450,6 +444,8 @@ impl RemoteControlWebsocket {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.server
|
||||
.set_app_server_client_name(app_server_client_name.clone());
|
||||
|
||||
loop {
|
||||
if !self.wait_until_enabled().await {
|
||||
@@ -572,24 +568,22 @@ impl RemoteControlWebsocket {
|
||||
|
||||
loop {
|
||||
let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone();
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let server = self.server.snapshot().await;
|
||||
info!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
server_name = %self.server_name,
|
||||
reconnect_attempt = self.reconnect_attempt.saturating_add(1),
|
||||
has_enrollment = enrollment.is_some(),
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
has_enrollment = server.is_some(),
|
||||
server_id = ?server.as_ref().map(|server| server.server_id.as_str()),
|
||||
environment_id = ?server.as_ref().map(|server| server.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
app_server_client_name = ?app_server_client_name,
|
||||
"connecting to app-server remote control websocket"
|
||||
);
|
||||
let connect_options = RemoteControlConnectOptions {
|
||||
installation_id: &self.installation_id,
|
||||
server_name: &self.server_name,
|
||||
subscribe_cursor: subscribe_cursor.as_deref(),
|
||||
app_server_client_name,
|
||||
};
|
||||
let auth_context = RemoteControlAuthContext {
|
||||
auth_manager: &self.auth_manager,
|
||||
@@ -606,9 +600,8 @@ impl RemoteControlWebsocket {
|
||||
}
|
||||
connect_result = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
self.state_db.as_deref(),
|
||||
auth_context,
|
||||
&mut self.enrollment,
|
||||
&self.server,
|
||||
connect_options,
|
||||
&self.status_publisher,
|
||||
) => connect_result,
|
||||
@@ -623,13 +616,13 @@ impl RemoteControlWebsocket {
|
||||
self.auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Connected);
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let server = self.server.snapshot().await;
|
||||
info!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
server_name = %self.server_name,
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
server_id = ?server.as_ref().map(|server| server.server_id.as_str()),
|
||||
environment_id = ?server.as_ref().map(|server| server.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
response_headers = %format_headers(response.headers()),
|
||||
"connected to app-server remote control websocket"
|
||||
@@ -648,7 +641,7 @@ impl RemoteControlWebsocket {
|
||||
let reconnect_attempt = self.reconnect_attempt.saturating_add(1);
|
||||
let (reconnect_delay, reconnect_backoff_reset) =
|
||||
next_reconnect_delay(&mut self.reconnect_attempt);
|
||||
let enrollment = self.enrollment.as_ref();
|
||||
let server = self.server.snapshot().await;
|
||||
warn!(
|
||||
websocket_url = %remote_control_target.websocket_url,
|
||||
installation_id = %self.installation_id,
|
||||
@@ -658,9 +651,9 @@ impl RemoteControlWebsocket {
|
||||
reconnect_attempt,
|
||||
reconnect_delay = ?reconnect_delay,
|
||||
reconnect_backoff_reset,
|
||||
has_enrollment = enrollment.is_some(),
|
||||
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
|
||||
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
|
||||
has_enrollment = server.is_some(),
|
||||
server_id = ?server.as_ref().map(|server| server.server_id.as_str()),
|
||||
environment_id = ?server.as_ref().map(|server| server.environment_id.as_str()),
|
||||
subscribe_cursor_present = subscribe_cursor.is_some(),
|
||||
"failed to connect to app-server remote control websocket"
|
||||
);
|
||||
@@ -696,7 +689,7 @@ impl RemoteControlWebsocket {
|
||||
}
|
||||
|
||||
async fn run_connection(
|
||||
&self,
|
||||
&mut self,
|
||||
websocket_connection: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
shutdown_token: CancellationToken,
|
||||
) -> ConnectionEndReason {
|
||||
@@ -731,6 +724,14 @@ impl RemoteControlWebsocket {
|
||||
ConnectionEndReason::EnabledWatchClosed
|
||||
}
|
||||
}
|
||||
changed = self.auth_change_rx.changed() => {
|
||||
if changed.is_ok() {
|
||||
self.auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
ConnectionEndReason::AuthChanged
|
||||
} else {
|
||||
ConnectionEndReason::AuthWatchClosed
|
||||
}
|
||||
}
|
||||
_ = join_set.join_next() => ConnectionEndReason::ConnectionWorkerStopped,
|
||||
};
|
||||
shutdown_token.cancel();
|
||||
@@ -1118,7 +1119,7 @@ fn set_remote_control_header(
|
||||
|
||||
fn build_remote_control_websocket_request(
|
||||
websocket_url: &str,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
server: &RemoteControlServer,
|
||||
installation_id: &str,
|
||||
subscribe_cursor: Option<&str>,
|
||||
) -> io::Result<tungstenite::http::Request<()>> {
|
||||
@@ -1129,11 +1130,11 @@ fn build_remote_control_websocket_request(
|
||||
)
|
||||
})?;
|
||||
let headers = request.headers_mut();
|
||||
set_remote_control_header(headers, "x-codex-server-id", &enrollment.server_id)?;
|
||||
set_remote_control_header(headers, "x-codex-server-id", &server.server_id)?;
|
||||
set_remote_control_header(
|
||||
headers,
|
||||
"x-codex-name",
|
||||
&base64::engine::general_purpose::STANDARD.encode(&enrollment.server_name),
|
||||
&base64::engine::general_purpose::STANDARD.encode(&server.server_name),
|
||||
)?;
|
||||
set_remote_control_header(
|
||||
headers,
|
||||
@@ -1145,7 +1146,7 @@ fn build_remote_control_websocket_request(
|
||||
"authorization",
|
||||
&format!(
|
||||
"Bearer {}",
|
||||
enrollment
|
||||
server
|
||||
.remote_control_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| io::Error::other("missing remote control server token"))?
|
||||
@@ -1166,51 +1167,6 @@ fn build_remote_control_websocket_request(
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_remote_control_auth(
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
) -> io::Result<RemoteControlConnectionAuth> {
|
||||
let mut reloaded = false;
|
||||
let auth = loop {
|
||||
let Some(auth) = auth_manager.auth().await else {
|
||||
if reloaded {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
"remote control requires ChatGPT authentication",
|
||||
));
|
||||
}
|
||||
auth_manager.reload().await;
|
||||
reloaded = true;
|
||||
continue;
|
||||
};
|
||||
if !auth.uses_codex_backend() {
|
||||
break auth;
|
||||
}
|
||||
if auth.get_account_id().is_none() && !reloaded {
|
||||
auth_manager.reload().await;
|
||||
reloaded = true;
|
||||
continue;
|
||||
}
|
||||
break auth;
|
||||
};
|
||||
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
"remote control requires ChatGPT authentication; API key auth is not supported",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(RemoteControlConnectionAuth {
|
||||
auth_provider: codex_model_provider::auth_provider_from_auth(&auth),
|
||||
account_id: auth.get_account_id().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
ErrorKind::WouldBlock,
|
||||
"remote control enrollment is waiting for a ChatGPT account id",
|
||||
)
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) {
|
||||
let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP);
|
||||
let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP;
|
||||
@@ -1224,9 +1180,8 @@ fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bo
|
||||
|
||||
pub(super) async fn connect_remote_control_websocket(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
state_db: Option<&StateRuntime>,
|
||||
mut auth_context: RemoteControlAuthContext<'_>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
auth_context: RemoteControlAuthContext<'_>,
|
||||
server: &SharedRemoteControlServer,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) -> io::Result<(
|
||||
@@ -1235,148 +1190,42 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
)> {
|
||||
ensure_rustls_crypto_provider();
|
||||
|
||||
let Some(state_db) = state_db else {
|
||||
*enrollment = None;
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"remote control requires sqlite state db",
|
||||
));
|
||||
};
|
||||
|
||||
let auth = match load_remote_control_auth(auth_context.auth_manager).await {
|
||||
Ok(auth) => auth,
|
||||
Err(err) => {
|
||||
if err.kind() == ErrorKind::PermissionDenied {
|
||||
*enrollment = None;
|
||||
server.clear().await;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id);
|
||||
if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) {
|
||||
info!(
|
||||
"clearing in-memory remote control enrollment because account id changed: websocket_url={}, previous_account_id={:?}, current_account_id={:?}",
|
||||
remote_control_target.websocket_url,
|
||||
enrollment
|
||||
.as_ref()
|
||||
.map(|enrollment| enrollment.account_id.as_str()),
|
||||
auth.account_id
|
||||
);
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
|
||||
if let Some(enrollment) = enrollment.as_ref() {
|
||||
status_publisher.publish_environment_id(Some(enrollment.environment_id.clone()));
|
||||
}
|
||||
|
||||
if enrollment.is_none() {
|
||||
let loaded_enrollment = load_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
)
|
||||
.await?;
|
||||
if let Some(loaded_enrollment) = loaded_enrollment.as_ref() {
|
||||
status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone()));
|
||||
}
|
||||
*enrollment = loaded_enrollment.map(|mut enrollment| {
|
||||
enrollment.server_name = connect_options.server_name.to_string();
|
||||
enrollment
|
||||
});
|
||||
}
|
||||
|
||||
enroll_remote_control_server_if_missing(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
&mut auth_context,
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if enrollment
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::other("missing remote control enrollment after enrollment step"))?
|
||||
.should_refresh_server_token()
|
||||
{
|
||||
let enrollment_ref = enrollment.as_ref().ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment after enrollment step")
|
||||
})?;
|
||||
let server_id = enrollment_ref.server_id.clone();
|
||||
let environment_id = enrollment_ref.environment_id.clone();
|
||||
|
||||
info!(
|
||||
"refreshing remote control server token: websocket_url={}, refresh_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
remote_control_target.refresh_url,
|
||||
auth.account_id,
|
||||
server_id,
|
||||
environment_id
|
||||
);
|
||||
let enrollment_ref = enrollment.as_mut().ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment before server refresh")
|
||||
})?;
|
||||
match refresh_remote_control_server(
|
||||
remote_control_target,
|
||||
&auth,
|
||||
connect_options.installation_id,
|
||||
enrollment_ref,
|
||||
)
|
||||
auth_context.auth_change_rx.borrow_and_update();
|
||||
let (_, server_snapshot) = match server
|
||||
.prepare_with_environment_updates(&auth, |environment_id| {
|
||||
status_publisher.publish_environment_id(environment_id);
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(server) => server,
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::PermissionDenied
|
||||
&& recover_remote_control_auth(
|
||||
auth_context.auth_recovery,
|
||||
auth_context.auth_change_rx,
|
||||
)
|
||||
.await =>
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
info!(
|
||||
"remote control server refresh returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url, auth.account_id, server_id, environment_id
|
||||
);
|
||||
clear_remote_control_enrollment(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
enrollment,
|
||||
status_publisher,
|
||||
)
|
||||
.await;
|
||||
enroll_remote_control_server_if_missing(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
&mut auth_context,
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::PermissionDenied
|
||||
&& recover_remote_control_auth(
|
||||
auth_context.auth_recovery,
|
||||
auth_context.auth_change_rx,
|
||||
)
|
||||
.await =>
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"{err}; retrying after auth recovery"
|
||||
)));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
return Err(io::Error::other(format!(
|
||||
"{err}; retrying after auth recovery"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let enrollment_ref = enrollment.as_ref().ok_or_else(|| {
|
||||
io::Error::other("missing remote control enrollment after enrollment step")
|
||||
})?;
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
status_publisher.publish_environment_id(Some(server_snapshot.environment_id.clone()));
|
||||
let request = build_remote_control_websocket_request(
|
||||
&remote_control_target.websocket_url,
|
||||
enrollment_ref,
|
||||
&server_snapshot,
|
||||
connect_options.installation_id,
|
||||
connect_options.subscribe_cursor,
|
||||
)?;
|
||||
@@ -1405,30 +1254,18 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
auth.account_id,
|
||||
enrollment_ref.server_id,
|
||||
enrollment_ref.environment_id
|
||||
server_snapshot.server_id,
|
||||
server_snapshot.environment_id
|
||||
);
|
||||
clear_remote_control_enrollment(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
enrollment,
|
||||
status_publisher,
|
||||
)
|
||||
.await;
|
||||
server
|
||||
.clear_server_if_current(remote_control_target, &server_snapshot)
|
||||
.await;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
tungstenite::Error::Http(response)
|
||||
if matches!(response.status().as_u16(), 401 | 403) =>
|
||||
{
|
||||
enrollment
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
io::Error::other(
|
||||
"missing remote control enrollment after websocket auth failure",
|
||||
)
|
||||
})?
|
||||
.clear_server_token();
|
||||
server.clear_server_token_if_current(&server_snapshot).await;
|
||||
return Err(io::Error::other(format!(
|
||||
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
|
||||
response.status()
|
||||
@@ -1446,94 +1283,6 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_remote_control_enrollment(
|
||||
state_db: &StateRuntime,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) {
|
||||
if let Err(clear_err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
account_id,
|
||||
app_server_client_name,
|
||||
/*enrollment*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to clear stale remote control enrollment in sqlite state db: {clear_err}");
|
||||
}
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
|
||||
async fn enroll_remote_control_server_if_missing(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
state_db: &StateRuntime,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
auth_context: &mut RemoteControlAuthContext<'_>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) -> io::Result<()> {
|
||||
if enrollment.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}",
|
||||
remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id
|
||||
);
|
||||
let new_enrollment = match enroll_remote_control_server(
|
||||
remote_control_target,
|
||||
auth,
|
||||
connect_options.installation_id,
|
||||
connect_options.server_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(new_enrollment) => new_enrollment,
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::PermissionDenied
|
||||
&& recover_remote_control_auth(
|
||||
auth_context.auth_recovery,
|
||||
auth_context.auth_change_rx,
|
||||
)
|
||||
.await =>
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"{err}; retrying after auth recovery"
|
||||
)));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if let Err(err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
Some(&new_enrollment),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to persist remote control enrollment in sqlite state db: {err}"
|
||||
)));
|
||||
}
|
||||
info!(
|
||||
"created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
new_enrollment.account_id,
|
||||
new_enrollment.server_id,
|
||||
new_enrollment.environment_id
|
||||
);
|
||||
status_publisher.publish_environment_id(Some(new_enrollment.environment_id.clone()));
|
||||
*enrollment = Some(new_enrollment);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recover_remote_control_auth(
|
||||
auth_recovery: &mut UnauthorizedRecovery,
|
||||
auth_change_rx: &mut watch::Receiver<u64>,
|
||||
@@ -1645,8 +1394,8 @@ mod tests {
|
||||
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
||||
const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token";
|
||||
|
||||
fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment {
|
||||
RemoteControlEnrollment {
|
||||
fn remote_control_server_state(remote_control_token: Option<&str>) -> RemoteControlServer {
|
||||
RemoteControlServer {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_test".to_string(),
|
||||
server_id: "srv_e_test".to_string(),
|
||||
@@ -1731,6 +1480,23 @@ mod tests {
|
||||
auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
}
|
||||
|
||||
async fn shared_remote_control_server(
|
||||
remote_control_url: String,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
server: Option<RemoteControlServer>,
|
||||
) -> SharedRemoteControlServer {
|
||||
let shared_server = SharedRemoteControlServer::new(
|
||||
remote_control_url,
|
||||
TEST_INSTALLATION_ID.to_string(),
|
||||
"test-server".to_string(),
|
||||
state_db,
|
||||
auth_manager,
|
||||
);
|
||||
shared_server.replace(server).await;
|
||||
shared_server
|
||||
}
|
||||
|
||||
fn remote_control_url_for_listener(listener: &TcpListener) -> String {
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
@@ -1807,25 +1573,28 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let server = shared_remote_control_server(
|
||||
remote_control_url,
|
||||
Some(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
Some(remote_control_server_state(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = match connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -1861,9 +1630,15 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let server = shared_remote_control_server(
|
||||
remote_control_url,
|
||||
Some(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
Some(remote_control_server_state(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
@@ -1877,18 +1652,15 @@ mod tests {
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -1910,8 +1682,8 @@ mod tests {
|
||||
"remote control websocket auth failed with HTTP 401 Unauthorized; refreshing server token before reconnect"
|
||||
);
|
||||
assert_eq!(
|
||||
enrollment,
|
||||
Some(remote_control_enrollment(
|
||||
server.snapshot().await,
|
||||
Some(remote_control_server_state(
|
||||
/*remote_control_token*/ None
|
||||
))
|
||||
);
|
||||
@@ -1951,7 +1723,13 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = None;
|
||||
let server = shared_remote_control_server(
|
||||
remote_control_url,
|
||||
Some(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
@@ -1962,18 +1740,15 @@ mod tests {
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2043,9 +1818,15 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(
|
||||
/*remote_control_token*/ None,
|
||||
));
|
||||
let server = shared_remote_control_server(
|
||||
remote_control_url,
|
||||
Some(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
Some(remote_control_server_state(
|
||||
/*remote_control_token*/ None,
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
@@ -2056,18 +1837,15 @@ mod tests {
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2114,25 +1892,28 @@ mod tests {
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let server = shared_remote_control_server(
|
||||
"http://127.0.0.1:9/backend-api/".to_string(),
|
||||
/*state_db*/ None,
|
||||
auth_manager.clone(),
|
||||
Some(remote_control_server_state(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
/*state_db*/ None,
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2141,7 +1922,7 @@ mod tests {
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::NotFound);
|
||||
assert_eq!(err.to_string(), "remote control requires sqlite state db");
|
||||
assert_eq!(enrollment, None);
|
||||
assert_eq!(server.snapshot().await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2159,9 +1940,15 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
let mut enrollment = Some(remote_control_enrollment(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
)));
|
||||
let server = shared_remote_control_server(
|
||||
"http://127.0.0.1:9/backend-api/".to_string(),
|
||||
Some(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
Some(remote_control_server_state(Some(
|
||||
TEST_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let (status_publisher, mut status_rx) = remote_control_status_channel();
|
||||
status_publisher.publish_environment_id(Some("env_test".to_string()));
|
||||
status_rx
|
||||
@@ -2171,18 +1958,15 @@ mod tests {
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
RemoteControlAuthContext {
|
||||
auth_manager: &auth_manager,
|
||||
auth_recovery: &mut auth_recovery,
|
||||
auth_change_rx: &mut auth_change_rx,
|
||||
},
|
||||
&mut enrollment,
|
||||
&server,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2194,7 +1978,7 @@ mod tests {
|
||||
err.to_string(),
|
||||
"remote control requires ChatGPT authentication"
|
||||
);
|
||||
assert_eq!(enrollment, None);
|
||||
assert_eq!(server.snapshot().await, None);
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
@@ -2221,6 +2005,14 @@ mod tests {
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (_enabled_tx, enabled_rx) = watch::channel(true);
|
||||
let auth_manager = remote_control_auth_manager();
|
||||
let server = SharedRemoteControlServer::new(
|
||||
remote_control_url.clone(),
|
||||
TEST_INSTALLATION_ID.to_string(),
|
||||
"test-server".to_string(),
|
||||
/*state_db*/ None,
|
||||
auth_manager.clone(),
|
||||
);
|
||||
let websocket_task = tokio::spawn({
|
||||
let shutdown_token = shutdown_token.clone();
|
||||
async move {
|
||||
@@ -2231,12 +2023,12 @@ mod tests {
|
||||
remote_control_target: Some(remote_control_target),
|
||||
server_name: "test-server".to_string(),
|
||||
},
|
||||
/*state_db*/ None,
|
||||
remote_control_auth_manager(),
|
||||
auth_manager,
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
},
|
||||
server,
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
)
|
||||
|
||||
@@ -209,6 +209,7 @@ Example with notification opt-out:
|
||||
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
|
||||
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
|
||||
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.
|
||||
- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`.
|
||||
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
|
||||
@@ -813,7 +813,6 @@ impl MessageProcessor {
|
||||
connection_request_id.request_id.clone(),
|
||||
&codex_request,
|
||||
);
|
||||
|
||||
let serialization_scope = codex_request.serialization_scope();
|
||||
let app_server_client_name = session.app_server_client_name().map(str::to_string);
|
||||
let client_version = session.client_version().map(str::to_string);
|
||||
@@ -915,6 +914,11 @@ impl MessageProcessor {
|
||||
.remote_control_processor
|
||||
.status_read()
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::RemoteControlPairingStart { params, .. } => self
|
||||
.remote_control_processor
|
||||
.pairing_start(params)
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
|
||||
.config_processor
|
||||
.config_requirements_read()
|
||||
|
||||
@@ -470,6 +470,8 @@ mod mcp_processor;
|
||||
mod plugins;
|
||||
mod process_exec_processor;
|
||||
mod remote_control_processor;
|
||||
#[cfg(test)]
|
||||
mod remote_control_processor_tests;
|
||||
mod search;
|
||||
mod thread_processor;
|
||||
mod token_usage_replay;
|
||||
|
||||
@@ -5,7 +5,10 @@ use crate::transport::RemoteControlUnavailable;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RemoteControlDisableResponse;
|
||||
use codex_app_server_protocol::RemoteControlEnableResponse;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use codex_app_server_protocol::RemoteControlStatusReadResponse;
|
||||
use std::io;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RemoteControlRequestProcessor {
|
||||
@@ -42,6 +45,16 @@ impl RemoteControlRequestProcessor {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn pairing_start(
|
||||
&self,
|
||||
params: RemoteControlPairingStartParams,
|
||||
) -> Result<RemoteControlPairingStartResponse, JSONRPCErrorError> {
|
||||
self.handle()?
|
||||
.start_pairing(params)
|
||||
.await
|
||||
.map_err(map_pairing_start_error)
|
||||
}
|
||||
|
||||
fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> {
|
||||
self.remote_control_handle
|
||||
.as_ref()
|
||||
@@ -52,3 +65,11 @@ impl RemoteControlRequestProcessor {
|
||||
fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError {
|
||||
invalid_request(err.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
|
||||
if err.kind() == io::ErrorKind::InvalidInput {
|
||||
invalid_request(err.to_string())
|
||||
} else {
|
||||
internal_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use super::remote_control_processor::RemoteControlRequestProcessor;
|
||||
use super::remote_control_processor::map_pairing_start_error;
|
||||
use crate::error_code::INTERNAL_ERROR_CODE;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io;
|
||||
|
||||
#[tokio::test]
|
||||
async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() {
|
||||
let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None)
|
||||
.pairing_start(RemoteControlPairingStartParams::default())
|
||||
.await
|
||||
.expect_err("missing remote control should fail pairing");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
data: None,
|
||||
message: "remote control is unavailable for this app-server".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_start_maps_invalid_input_to_invalid_request() {
|
||||
assert_eq!(
|
||||
map_pairing_start_error(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"remote control pairing is unavailable",
|
||||
)),
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
data: None,
|
||||
message: "remote control pairing is unavailable".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_start_maps_backend_failures_to_internal_error() {
|
||||
assert_eq!(
|
||||
map_pairing_start_error(io::Error::other("remote control pairing failed")),
|
||||
JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
data: None,
|
||||
message: "remote control pairing failed".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -636,6 +636,16 @@ impl McpProcess {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `remoteControl/pairing/start` JSON-RPC request.
|
||||
pub async fn send_remote_control_pairing_start_request(
|
||||
&mut self,
|
||||
params: codex_app_server_protocol::RemoteControlPairingStartParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("remoteControl/pairing/start", params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `remoteControl/status/read` JSON-RPC request.
|
||||
pub async fn send_remote_control_status_read_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("remoteControl/status/read", /*params*/ None)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -11,18 +12,22 @@ use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlDisableResponse;
|
||||
use codex_app_server_protocol::RemoteControlEnableResponse;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
||||
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
||||
use codex_app_server_protocol::RemoteControlStatusReadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::accept_hdr_async;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
@@ -125,6 +130,192 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
enable_connected_remote_control(&mut mcp, &mut backend).await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_remote_control_pairing_start_request(RemoteControlPairingStartParams {
|
||||
manual_code: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: RemoteControlPairingStartResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
received,
|
||||
RemoteControlPairingStartResponse {
|
||||
pairing_code: "pairing-code".to_string(),
|
||||
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
||||
environment_id: "environment-id".to_string(),
|
||||
expires_at: 33_336_362_096,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PairingRemoteControlBackend {
|
||||
enroll_request_rx: Option<oneshot::Receiver<Result<String>>>,
|
||||
websocket_request_rx: Option<oneshot::Receiver<Result<String>>>,
|
||||
server_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
async fn enable_connected_remote_control(
|
||||
mcp: &mut McpProcess,
|
||||
backend: &mut PairingRemoteControlBackend,
|
||||
) -> Result<()> {
|
||||
let request_id = mcp.send_remote_control_enable_request().await?;
|
||||
let _: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
assert_eq!(
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_websocket_request()).await??,
|
||||
"GET /backend-api/wham/remote/control/server HTTP/1.1"
|
||||
);
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let request_id = mcp.send_remote_control_status_read_request().await?;
|
||||
let response: JSONRPCResponse = mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(request_id))
|
||||
.await?;
|
||||
let received: RemoteControlStatusReadResponse = to_response(response)?;
|
||||
if received.status == RemoteControlConnectionStatus::Connected {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
impl PairingRemoteControlBackend {
|
||||
async fn start(codex_home: &Path) -> Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?);
|
||||
write_mock_responses_config_toml_with_chatgpt_base_url(
|
||||
codex_home,
|
||||
&remote_control_url,
|
||||
&remote_control_url,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home,
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account_id")
|
||||
.chatgpt_account_id("account_id"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let (enroll_request_tx, enroll_request_rx) = oneshot::channel();
|
||||
let (websocket_request_tx, websocket_request_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let mut enroll_request_tx = Some(enroll_request_tx);
|
||||
let mut websocket_request_tx = Some(websocket_request_tx);
|
||||
let result = async {
|
||||
let (enroll_request_line, enroll_reader) = read_enroll_request(&listener).await?;
|
||||
if let Some(enroll_request_tx) = enroll_request_tx.take() {
|
||||
let _ = enroll_request_tx.send(Ok(enroll_request_line));
|
||||
}
|
||||
respond_with_json(
|
||||
enroll_reader.into_inner(),
|
||||
serde_json::json!({
|
||||
"server_id": "server-id",
|
||||
"environment_id": "environment-id",
|
||||
"remote_control_token": "remote-control-token",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
"scopes": ["remote_control_server_websocket"],
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (websocket_stream, _) = listener.accept().await?;
|
||||
let websocket_request_tx = websocket_request_tx.take();
|
||||
let mut websocket = accept_hdr_async(
|
||||
websocket_stream,
|
||||
move |request: &tokio_tungstenite::tungstenite::handshake::server::Request,
|
||||
response: tokio_tungstenite::tungstenite::handshake::server::Response| {
|
||||
let method = request.method();
|
||||
let uri = request.uri();
|
||||
let request_line = format!("{method} {uri} HTTP/1.1");
|
||||
if let Some(websocket_request_tx) = websocket_request_tx {
|
||||
let _ = websocket_request_tx.send(Ok(request_line));
|
||||
}
|
||||
Ok(response)
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (request_line, request_reader) = read_enroll_request(&listener).await?;
|
||||
if request_line != "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" {
|
||||
anyhow::bail!("unexpected remote control request: {request_line}");
|
||||
}
|
||||
respond_with_json(
|
||||
request_reader.into_inner(),
|
||||
serde_json::json!({
|
||||
"pairing_code": "pairing-code",
|
||||
"manual_pairing_code": "ABCD-EFGH",
|
||||
"server_id": "server-id",
|
||||
"environment_id": "environment-id",
|
||||
"expires_at": "3026-05-22T12:34:56Z",
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
websocket.close(None).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
if let Some(enroll_request_tx) = enroll_request_tx {
|
||||
let _ = enroll_request_tx.send(Err(anyhow::anyhow!(err.to_string())));
|
||||
}
|
||||
if let Some(websocket_request_tx) = websocket_request_tx {
|
||||
let _ = websocket_request_tx.send(Err(anyhow::anyhow!(err.to_string())));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
enroll_request_rx: Some(enroll_request_rx),
|
||||
websocket_request_rx: Some(websocket_request_rx),
|
||||
server_task,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_enroll_request(&mut self) -> Result<String> {
|
||||
self.enroll_request_rx
|
||||
.take()
|
||||
.context("enroll request should only be awaited once")?
|
||||
.await?
|
||||
}
|
||||
|
||||
async fn wait_for_websocket_request(&mut self) -> Result<String> {
|
||||
self.websocket_request_rx
|
||||
.take()
|
||||
.context("websocket request should only be awaited once")?
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PairingRemoteControlBackend {
|
||||
fn drop(&mut self) {
|
||||
self.server_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
struct BlockingRemoteControlBackend {
|
||||
enroll_request_rx: Option<oneshot::Receiver<Result<String>>>,
|
||||
server_task: JoinHandle<()>,
|
||||
@@ -149,7 +340,7 @@ impl BlockingRemoteControlBackend {
|
||||
|
||||
let (enroll_request_tx, enroll_request_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
match read_enroll_request(listener).await {
|
||||
match read_enroll_request(&listener).await {
|
||||
Ok((request_line, _reader)) => {
|
||||
let _ = enroll_request_tx.send(Ok(request_line));
|
||||
std::future::pending::<()>().await;
|
||||
@@ -181,7 +372,7 @@ impl Drop for BlockingRemoteControlBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader<TcpStream>)> {
|
||||
async fn read_enroll_request(listener: &TcpListener) -> Result<(String, BufReader<TcpStream>)> {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
|
||||
@@ -198,3 +389,25 @@ async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader
|
||||
|
||||
Ok((request_line.trim_end().to_string(), reader))
|
||||
}
|
||||
|
||||
async fn respond_with_json(stream: TcpStream, body: serde_json::Value) -> Result<()> {
|
||||
let body = body.to_string();
|
||||
respond_with_status_and_body(stream, "200 OK", &body).await
|
||||
}
|
||||
|
||||
async fn respond_with_status_and_body(
|
||||
mut stream: TcpStream,
|
||||
status: &str,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
stream
|
||||
.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user