feat(app-server): persist remote-control desired state (#27445)

## Why

Remote-control runtime enablement and persisted enrollment preference
were represented by separate flags. That made startup rehydration, RPC
persistence, and new-enrollment seeding race with one another, and it
did not cleanly distinguish runtime-only CLI or daemon starts from
durable app-server RPC changes.

## What Changed

- Replace the parallel enablement, seed, and rehydration flags with one
transport-owned `RemoteControlDesiredState`.
- Add nullable enrollment-scoped persistence and preserve existing
preferences during enrollment upserts.
- Rehydrate plain startup only after auth and client scope resolve,
without overwriting a concurrent RPC transition.
- Make ordinary `remoteControl/enable` and `remoteControl/disable`
durable while retaining `ephemeral: true` for runtime-only callers.
- Have the daemon explicitly request ephemeral enablement and regenerate
the app-server schemas.

## Verification

- Covered migration and `NULL`/`0`/`1` persistence round trips.
- Covered plain-start rehydration and runtime-only versus durable
enrollment seeding.
- Covered durable enable, durable disable, and ephemeral enable through
app-server RPC.
- Covered the daemon's exact `{ "ephemeral": true }` request payload.

Related issue: N/A (internal remote-control persistence architecture
change).
This commit is contained in:
Anton Panasenko authored and GitHub committed 2026-06-11 21:28:52 -07:00
1 parent be338ee9a2
commit d61dfeb23a
33 files changed
+2157 -412

No files matched your search

@@ -8,9 +8,13 @@ use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlDisableParams;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableParams;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_app_server_protocol::RequestId;
use serde::de::DeserializeOwned;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::time::Instant;
@@ -22,13 +26,33 @@ use crate::RemoteControlReadyStatus;
use crate::client;
const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_CONTROL_ENABLE_REQUEST_ID: RequestId = RequestId::Integer(2);
const REMOTE_CONTROL_REQUEST_ID: RequestId = RequestId::Integer(2);
const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
enum RemoteControlRpcResponse<T> {
Success(T),
InvalidParams,
}
pub(crate) async fn enable_remote_control(socket_path: &Path) -> Result<RemoteControlReadyStatus> {
let mut websocket = client::connect(socket_path).await?;
enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await
}
pub(crate) async fn disable_remote_control(socket_path: &Path) -> Result<RemoteControlReadyStatus> {
let mut websocket = client::connect(socket_path).await?;
initialize_client(&mut websocket).await?;
let params = serde_json::to_value(RemoteControlDisableParams { ephemeral: true })?;
let response: RemoteControlDisableResponse = request_remote_control_with_legacy_fallback(
&mut websocket,
"remoteControl/disable",
params,
)
.await?;
websocket.close(None).await.ok();
Ok(RemoteControlReadyStatus::from(response))
}
pub(crate) async fn enable_remote_control_with_connect_retry(
socket_path: &Path,
connect_timeout: Duration,
@@ -43,6 +67,26 @@ async fn enable_remote_control_with_timeout<S>(
websocket: &mut WebSocketStream<S>,
ready_timeout: Duration,
) -> Result<RemoteControlReadyStatus>
where
S: AsyncRead + AsyncWrite + Unpin,
{
initialize_client(websocket).await?;
let response: RemoteControlEnableResponse = request_remote_control_with_legacy_fallback(
websocket,
"remoteControl/enable",
serde_json::to_value(RemoteControlEnableParams { ephemeral: true })?,
)
.await?;
let mut latest = RemoteControlReadyStatus::from(response);
if latest.status == RemoteControlConnectionStatus::Connecting {
latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?;
}
websocket.close(None).await.ok();
Ok(latest)
}
async fn initialize_client<S>(websocket: &mut WebSocketStream<S>) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
@@ -53,24 +97,65 @@ where
});
client::send_message(websocket, &initialized)
.await
.context("failed to send initialized notification")?;
.context("failed to send initialized notification")
}
let enable = JSONRPCMessage::Request(JSONRPCRequest {
id: REMOTE_CONTROL_ENABLE_REQUEST_ID,
method: "remoteControl/enable".to_string(),
params: None,
async fn send_remote_control_request<S>(
websocket: &mut WebSocketStream<S>,
request_id: RequestId,
method: &str,
params: Option<serde_json::Value>,
) -> Result<()>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let request = JSONRPCMessage::Request(JSONRPCRequest {
id: request_id,
method: method.to_string(),
params,
trace: None,
});
client::send_message(websocket, &enable)
client::send_message(websocket, &request)
.await
.context("failed to send remoteControl/enable request")?;
.with_context(|| format!("failed to send {method} request"))
}
let mut latest = read_enable_response(websocket).await?;
if latest.status == RemoteControlConnectionStatus::Connecting {
latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?;
async fn request_remote_control_with_legacy_fallback<S, T>(
websocket: &mut WebSocketStream<S>,
method: &str,
params: serde_json::Value,
) -> Result<T>
where
S: AsyncRead + AsyncWrite + Unpin,
T: DeserializeOwned,
{
send_remote_control_request(
websocket,
REMOTE_CONTROL_REQUEST_ID.clone(),
method,
Some(params),
)
.await?;
match read_remote_control_response(websocket, &REMOTE_CONTROL_REQUEST_ID, method).await? {
RemoteControlRpcResponse::Success(response) => Ok(response),
RemoteControlRpcResponse::InvalidParams => {
send_remote_control_request(
websocket,
REMOTE_CONTROL_REQUEST_ID.clone(),
method,
/*params*/ None,
)
.await?;
match read_remote_control_response(websocket, &REMOTE_CONTROL_REQUEST_ID, method)
.await?
{
RemoteControlRpcResponse::Success(response) => Ok(response),
RemoteControlRpcResponse::InvalidParams => {
Err(anyhow!("{method} rejected legacy params"))
}
}
}
}
websocket.close(None).await.ok();
Ok(latest)
}
async fn connect_with_retry(
@@ -97,11 +182,14 @@ async fn connect_with_retry(
}
}
async fn read_enable_response<S>(
async fn read_remote_control_response<S, T>(
websocket: &mut WebSocketStream<S>,
) -> Result<RemoteControlReadyStatus>
request_id: &RequestId,
method: &str,
) -> Result<RemoteControlRpcResponse<T>>
where
S: AsyncRead + AsyncWrite + Unpin,
T: DeserializeOwned,
{
loop {
let message = timeout(
@@ -109,21 +197,20 @@ where
client::read_message(websocket),
)
.await
.context("timed out waiting for remoteControl/enable response")??;
.with_context(|| format!("timed out waiting for {method} response"))??;
match message {
JSONRPCMessage::Response(response)
if response.id == REMOTE_CONTROL_ENABLE_REQUEST_ID =>
{
let response =
serde_json::from_value::<RemoteControlEnableResponse>(response.result)
.context("failed to parse remoteControl/enable response")?;
return Ok(RemoteControlReadyStatus::from(response));
JSONRPCMessage::Response(response) if response.id == *request_id => {
let response = serde_json::from_value::<T>(response.result)
.with_context(|| format!("failed to parse {method} response"))?;
return Ok(RemoteControlRpcResponse::Success(response));
}
JSONRPCMessage::Error(err) if err.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => {
return Err(anyhow!(
"remoteControl/enable failed: {}",
err.error.message
));
JSONRPCMessage::Error(err)
if err.id == *request_id && err.error.code == INVALID_PARAMS_ERROR_CODE =>
{
return Ok(RemoteControlRpcResponse::InvalidParams);
}
JSONRPCMessage::Error(err) if err.id == *request_id => {
return Err(anyhow!("{method} failed: {}", err.error.message));
}
JSONRPCMessage::Notification(notification)
if remote_control_status_notification(&notification).is_some() =>
@@ -196,6 +283,23 @@ impl From<RemoteControlEnableResponse> for RemoteControlReadyStatus {
}
}
impl From<RemoteControlDisableResponse> for RemoteControlReadyStatus {
fn from(response: RemoteControlDisableResponse) -> Self {
let RemoteControlDisableResponse {
status,
server_name,
installation_id: _,
environment_id,
} = response;
Self {
status,
server_name,
environment_id,
timed_out: false,
}
}
}
impl From<RemoteControlStatusChangedNotification> for RemoteControlReadyStatus {
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
let RemoteControlStatusChangedNotification {
@@ -216,6 +320,8 @@ impl From<RemoteControlStatusChangedNotification> for RemoteControlReadyStatus {
#[cfg(all(test, unix))]
mod tests {
use anyhow::Result;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_uds::UnixListener;
use pretty_assertions::assert_eq;
@@ -243,6 +349,7 @@ mod tests {
),
after_enable_notification: None,
ready_timeout: Duration::from_millis(20),
reject_ephemeral_params: false,
})
.await?;
@@ -271,6 +378,7 @@ mod tests {
Some("env_test"),
)),
ready_timeout: Duration::from_secs(1),
reject_ephemeral_params: false,
})
.await?;
@@ -296,6 +404,7 @@ mod tests {
),
after_enable_notification: None,
ready_timeout: Duration::from_millis(20),
reject_ephemeral_params: false,
})
.await?;
@@ -321,6 +430,7 @@ mod tests {
),
after_enable_notification: None,
ready_timeout: Duration::from_millis(20),
reject_ephemeral_params: false,
})
.await?;
@@ -336,11 +446,104 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn enable_remote_control_retries_without_params_for_older_servers() -> Result<()> {
let status = run_enable_remote_control_scenario(EnableScenario {
initial_notification: None,
enable_response: remote_control_status(
RemoteControlConnectionStatus::Connected,
Some("env_test"),
),
after_enable_notification: None,
ready_timeout: Duration::from_millis(20),
reject_ephemeral_params: true,
})
.await?;
assert_eq!(
status,
RemoteControlReadyStatus {
status: RemoteControlConnectionStatus::Connected,
server_name: TEST_SERVER_NAME.to_string(),
environment_id: Some("env_test".to_string()),
timed_out: false,
}
);
Ok(())
}
#[tokio::test]
async fn disable_remote_control_retries_without_params_for_older_servers() -> Result<()> {
let dir = TempDir::new()?;
let socket_path = dir.path().join("app-server.sock");
let listener = UnixListener::bind(&socket_path).await?;
let server_task = tokio::spawn(async move {
let mut websocket = accept_initialized_client(listener).await?;
let disable = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(disable) = disable else {
panic!("expected remoteControl/disable request");
};
assert_eq!(disable.id, REMOTE_CONTROL_REQUEST_ID);
assert_eq!(disable.method, "remoteControl/disable");
assert_eq!(
disable.params,
Some(serde_json::json!({ "ephemeral": true }))
);
client::send_message(
&mut websocket,
&JSONRPCMessage::Error(JSONRPCError {
id: REMOTE_CONTROL_REQUEST_ID,
error: JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "Invalid params".to_string(),
data: None,
},
}),
)
.await?;
let fallback = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(fallback) = fallback else {
panic!("expected fallback remoteControl/disable request");
};
assert_eq!(fallback.id, REMOTE_CONTROL_REQUEST_ID);
assert_eq!(fallback.method, "remoteControl/disable");
assert_eq!(fallback.params, None);
client::send_message(
&mut websocket,
&JSONRPCMessage::Response(JSONRPCResponse {
id: REMOTE_CONTROL_REQUEST_ID,
result: serde_json::to_value(RemoteControlDisableResponse::from(
remote_control_status(
RemoteControlConnectionStatus::Disabled,
/*environment_id*/ None,
),
))?,
}),
)
.await?;
Ok::<_, anyhow::Error>(())
});
let status = disable_remote_control(&socket_path).await?;
server_task.await??;
assert_eq!(
status,
RemoteControlReadyStatus {
status: RemoteControlConnectionStatus::Disabled,
server_name: TEST_SERVER_NAME.to_string(),
environment_id: None,
timed_out: false,
}
);
Ok(())
}
struct EnableScenario {
initial_notification: Option<RemoteControlStatusChangedNotification>,
enable_response: RemoteControlStatusChangedNotification,
after_enable_notification: Option<RemoteControlStatusChangedNotification>,
ready_timeout: Duration,
reject_ephemeral_params: bool,
}
async fn run_enable_remote_control_scenario(
@@ -359,12 +562,70 @@ mod tests {
}
async fn serve_enable_remote_control_scenario(
mut listener: UnixListener,
listener: UnixListener,
scenario: EnableScenario,
) -> Result<()> {
let mut websocket = accept_initialized_client(listener).await?;
if let Some(status) = scenario.initial_notification {
send_remote_control_status(&mut websocket, status).await?;
}
let enable = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(enable) = enable else {
panic!("expected remoteControl/enable request");
};
assert_eq!(enable.id, REMOTE_CONTROL_REQUEST_ID);
assert_eq!(enable.method, "remoteControl/enable");
assert_eq!(
enable.params,
Some(serde_json::json!({ "ephemeral": true }))
);
if scenario.reject_ephemeral_params {
client::send_message(
&mut websocket,
&JSONRPCMessage::Error(JSONRPCError {
id: REMOTE_CONTROL_REQUEST_ID,
error: JSONRPCErrorError {
code: INVALID_PARAMS_ERROR_CODE,
message: "Invalid params".to_string(),
data: None,
},
}),
)
.await?;
let fallback = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(fallback) = fallback else {
panic!("expected fallback remoteControl/enable request");
};
assert_eq!(fallback.id, REMOTE_CONTROL_REQUEST_ID);
assert_eq!(fallback.method, "remoteControl/enable");
assert_eq!(fallback.params, None);
}
client::send_message(
&mut websocket,
&JSONRPCMessage::Response(JSONRPCResponse {
id: REMOTE_CONTROL_REQUEST_ID,
result: serde_json::to_value(RemoteControlEnableResponse::from(
scenario.enable_response,
))?,
}),
)
.await?;
if let Some(status) = scenario.after_enable_notification {
send_remote_control_status(&mut websocket, status).await?;
} else {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
}
async fn accept_initialized_client(
mut listener: UnixListener,
) -> Result<WebSocketStream<codex_uds::UnixStream>> {
let stream = listener.accept().await?;
let mut websocket = accept_async(stream).await?;
let initialize = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(initialize) = initialize else {
panic!("expected initialize request");
@@ -397,35 +658,7 @@ mod tests {
panic!("expected initialized notification");
};
assert_eq!(initialized.method, "initialized");
if let Some(status) = scenario.initial_notification {
send_remote_control_status(&mut websocket, status).await?;
}
let enable = client::read_message(&mut websocket).await?;
let JSONRPCMessage::Request(enable) = enable else {
panic!("expected remoteControl/enable request");
};
assert_eq!(enable.id, REMOTE_CONTROL_ENABLE_REQUEST_ID);
assert_eq!(enable.method, "remoteControl/enable");
client::send_message(
&mut websocket,
&JSONRPCMessage::Response(JSONRPCResponse {
id: REMOTE_CONTROL_ENABLE_REQUEST_ID,
result: serde_json::to_value(RemoteControlEnableResponse::from(
scenario.enable_response,
))?,
}),
)
.await?;
if let Some(status) = scenario.after_enable_notification {
send_remote_control_status(&mut websocket, status).await?;
} else {
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(())
Ok(websocket)
}
async fn send_remote_control_status<S>(