mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
be338ee9a2
commit
d61dfeb23a
@@ -8,6 +8,8 @@ use std::time::Duration;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
#[cfg(unix)]
|
||||
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
@@ -164,6 +166,9 @@ impl PidBackend {
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_log.into_std().await));
|
||||
if let Some((key, value)) = self.command_env() {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -407,6 +412,19 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn command_env(&self) -> Option<(&'static str, &'static str)> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
remote_control_enabled: false,
|
||||
} => Some((REMOTE_CONTROL_DISABLED_ENV_VAR, "1")),
|
||||
PidCommandKind::AppServer {
|
||||
remote_control_enabled: true,
|
||||
}
|
||||
| PidCommandKind::UpdateLoop => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_process(&self, pid: u32) -> Result<()> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer { .. } => terminate_process(pid),
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::time::Duration;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
|
||||
use super::PidBackend;
|
||||
use super::PidCommandKind;
|
||||
use super::PidFileState;
|
||||
@@ -174,6 +176,24 @@ fn app_server_remote_control_uses_runtime_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_disabled_remote_control_uses_compatible_args_and_runtime_env() {
|
||||
let backend = PidBackend::new(
|
||||
"codex".into(),
|
||||
"app-server.pid".into(),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
backend.command_args(),
|
||||
vec!["app-server", "--listen", "unix://"]
|
||||
);
|
||||
assert_eq!(
|
||||
backend.command_env(),
|
||||
Some((REMOTE_CONTROL_DISABLED_ENV_VAR, "1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_stderr_log_tail_returns_recent_complete_lines() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -543,6 +543,16 @@ impl Daemon {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if info.is_some() {
|
||||
match mode {
|
||||
RemoteControlMode::Enabled => {
|
||||
remote_control_client::enable_remote_control(&self.socket_path).await?;
|
||||
}
|
||||
RemoteControlMode::Disabled => {
|
||||
remote_control_client::disable_remote_control(&self.socket_path).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(self.remote_control_output(
|
||||
already_remote_control_status(mode),
|
||||
backend.map(|_| BackendKind::Pid),
|
||||
|
||||
@@ -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(¬ification).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>(
|
||||
|
||||
@@ -2124,6 +2124,22 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"RemoteControlDisableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlEnableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+16
@@ -14050,6 +14050,22 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlDisableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlEnableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlStatusChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current remote-control connection status and remote identity exposed to clients.",
|
||||
|
||||
+16
@@ -10532,6 +10532,22 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlDisableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlEnableParams": {
|
||||
"properties": {
|
||||
"ephemeral": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlStatusChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current remote-control connection status and remote identity exposed to clients.",
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RemoteControlDisableParams = { ephemeral?: boolean, };
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RemoteControlEnableParams = { ephemeral?: boolean, };
|
||||
@@ -325,6 +325,8 @@ export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPa
|
||||
export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification";
|
||||
export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification";
|
||||
export type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus";
|
||||
export type { RemoteControlDisableParams } from "./RemoteControlDisableParams";
|
||||
export type { RemoteControlEnableParams } from "./RemoteControlEnableParams";
|
||||
export type { RemoteControlStatusChangedNotification } from "./RemoteControlStatusChangedNotification";
|
||||
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
|
||||
export type { ResidencyRequirement } from "./ResidencyRequirement";
|
||||
|
||||
@@ -864,13 +864,13 @@ client_request_definitions! {
|
||||
},
|
||||
#[experimental("remoteControl/enable")]
|
||||
RemoteControlEnable => "remoteControl/enable" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
params: #[serde(skip_serializing_if = "Option::is_none")] v2::NullableRemoteControlEnableParams,
|
||||
serialization: global("remote-control"),
|
||||
response: v2::RemoteControlEnableResponse,
|
||||
},
|
||||
#[experimental("remoteControl/disable")]
|
||||
RemoteControlDisable => "remoteControl/disable" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
params: #[serde(skip_serializing_if = "Option::is_none")] v2::NullableRemoteControlDisableParams,
|
||||
serialization: global("remote-control"),
|
||||
response: v2::RemoteControlDisableResponse,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,26 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RemoteControlEnableParams {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub type NullableRemoteControlEnableParams = Option<RemoteControlEnableParams>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RemoteControlDisableParams {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub type NullableRemoteControlDisableParams = Option<RemoteControlDisableParams>;
|
||||
|
||||
/// Current remote-control connection status and remote identity exposed to clients.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -11,8 +11,10 @@ pub use transport::AppServerTransport;
|
||||
pub use transport::AppServerTransportParseError;
|
||||
pub use transport::CHANNEL_CAPACITY;
|
||||
pub use transport::ConnectionOrigin;
|
||||
pub use transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
pub use transport::RemoteControlHandle;
|
||||
pub use transport::RemoteControlStartConfig;
|
||||
pub use transport::RemoteControlStartupMode;
|
||||
pub use transport::RemoteControlUnavailable;
|
||||
pub use transport::TransportEvent;
|
||||
pub use transport::acquire_app_server_startup_lock;
|
||||
@@ -24,3 +26,4 @@ pub use transport::start_control_socket_acceptor;
|
||||
pub use transport::start_remote_control;
|
||||
pub use transport::start_stdio_connection;
|
||||
pub use transport::start_websocket_acceptor;
|
||||
pub use transport::take_remote_control_disabled_env;
|
||||
|
||||
@@ -30,10 +30,13 @@ mod unix_socket;
|
||||
mod unix_socket_tests;
|
||||
mod websocket;
|
||||
|
||||
pub use remote_control::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
pub use remote_control::RemoteControlHandle;
|
||||
pub use remote_control::RemoteControlStartConfig;
|
||||
pub use remote_control::RemoteControlStartupMode;
|
||||
pub use remote_control::RemoteControlUnavailable;
|
||||
pub use remote_control::start_remote_control;
|
||||
pub use remote_control::take_remote_control_disabled_env;
|
||||
pub use stdio::start_stdio_connection;
|
||||
pub use unix_socket::AppServerStartupLock;
|
||||
pub use unix_socket::acquire_app_server_startup_lock;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
use super::RemoteControlHandle;
|
||||
use super::RemoteControlUnavailable;
|
||||
use super::enroll::update_persisted_remote_control_enrollment;
|
||||
use super::protocol::normalize_remote_control_url;
|
||||
use super::publish_current_enrollment;
|
||||
use super::websocket::RemoteControlStatusPublisher;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_state::RemoteControlEnrollmentRecord;
|
||||
use std::io;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::SemaphorePermit;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum RemoteControlDesiredState {
|
||||
// `Unknown` exists only on plain startup before auth and enrollment scope resolve. Persisted
|
||||
// `1` is `Enabled { persistence_preference: Some(true) }`; `0`, `NULL`, or no row are
|
||||
// `Disabled`. Runtime-only enable is `Enabled { persistence_preference: None }`, so new rows
|
||||
// keep `NULL`; durable RPC enable uses `Some(true)`, so new rows get `1`. Durable disable writes
|
||||
// `0` before entering `Disabled`; runtime-only disable does not write. `Disabled` carries no
|
||||
// preference because disabled sessions do not create enrollments.
|
||||
Unknown,
|
||||
Disabled,
|
||||
Enabled {
|
||||
persistence_preference: Option<bool>,
|
||||
},
|
||||
}
|
||||
impl RemoteControlDesiredState {
|
||||
pub(super) fn is_enabled(self) -> bool {
|
||||
matches!(self, Self::Enabled { .. })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn acquire_persistence_lock(lock: &Semaphore) -> SemaphorePermit<'_> {
|
||||
lock.acquire().await.unwrap_or_else(|_| unreachable!())
|
||||
}
|
||||
|
||||
pub(super) fn desired_state_from_persisted_enrollment(
|
||||
enrollment: Option<RemoteControlEnrollmentRecord>,
|
||||
) -> RemoteControlDesiredState {
|
||||
if enrollment.and_then(|enrollment| enrollment.remote_control_enabled) == Some(true) {
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
}
|
||||
} else {
|
||||
RemoteControlDesiredState::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteControlHandle {
|
||||
pub async fn resolve_persisted_preference(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<bool> {
|
||||
let _transition = self
|
||||
.desired_state_rpc_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
if !matches!(
|
||||
*self.desired_state_tx.borrow(),
|
||||
RemoteControlDesiredState::Unknown
|
||||
) {
|
||||
return Ok(self.desired_state_tx.borrow().is_enabled());
|
||||
}
|
||||
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, RemoteControlUnavailable))?;
|
||||
let auth = super::auth::load_remote_control_auth(&self.auth_manager).await?;
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?;
|
||||
let enrollment = state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
&auth.account_id,
|
||||
app_server_client_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
let desired_state = desired_state_from_persisted_enrollment(enrollment);
|
||||
self.desired_state_tx.send_if_modified(|state| {
|
||||
if !matches!(*state, RemoteControlDesiredState::Unknown) {
|
||||
return false;
|
||||
}
|
||||
*state = desired_state;
|
||||
true
|
||||
});
|
||||
Ok(self.desired_state_tx.borrow().is_enabled())
|
||||
}
|
||||
|
||||
pub async fn enable(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<RemoteControlStatusChangedNotification> {
|
||||
let _transition = self
|
||||
.desired_state_rpc_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, RemoteControlUnavailable))?;
|
||||
let mut auth = super::auth::load_remote_control_auth(&self.auth_manager).await?;
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?;
|
||||
let app_server_client_name = app_server_client_name.as_deref();
|
||||
let status = self.status();
|
||||
let mut current_enrollment = self.current_enrollment.lock().await;
|
||||
let (enrollment, _) = self
|
||||
.load_or_enroll_server(
|
||||
¤t_enrollment,
|
||||
&mut auth,
|
||||
&status.installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
super::RemoteControlEnrollmentSelection::ReuseOrCreate,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let current_auth = super::auth::load_remote_control_auth(&self.auth_manager).await?;
|
||||
if current_auth.account_id != auth.account_id {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Interrupted,
|
||||
"remote control account changed during enrollment",
|
||||
));
|
||||
}
|
||||
|
||||
let _persistence = acquire_persistence_lock(&self.desired_state_persistence_lock).await;
|
||||
let updated = state_db
|
||||
.set_remote_control_enabled(
|
||||
&remote_control_target.websocket_url,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
/*remote_control_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
if updated == 0 {
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
Some(&enrollment),
|
||||
Some(true),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
publish_current_enrollment(&mut current_enrollment, &enrollment);
|
||||
self.enable_with_preference(Some(true))
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?;
|
||||
RemoteControlStatusPublisher::new(self.status_tx.as_ref().clone())
|
||||
.publish_environment_id(Some(enrollment.environment_id));
|
||||
Ok(self.status())
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,7 @@ pub(super) async fn update_persisted_remote_control_enrollment(
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: Option<&RemoteControlEnrollment>,
|
||||
remote_control_enabled: Option<bool>,
|
||||
) -> io::Result<()> {
|
||||
let Some(state_db) = state_db else {
|
||||
return Err(io::Error::new(
|
||||
@@ -316,6 +317,7 @@ pub(super) async fn update_persisted_remote_control_enrollment(
|
||||
server_id: enrollment.server_id.clone(),
|
||||
environment_id: enrollment.environment_id.clone(),
|
||||
server_name: enrollment.server_name.clone(),
|
||||
remote_control_enabled,
|
||||
})
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
@@ -648,6 +650,7 @@ mod tests {
|
||||
"account-a",
|
||||
Some("desktop-client"),
|
||||
Some(&first_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("first enrollment should persist");
|
||||
@@ -657,6 +660,7 @@ mod tests {
|
||||
"account-a",
|
||||
Some("desktop-client"),
|
||||
Some(&second_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("second enrollment should persist");
|
||||
@@ -730,6 +734,7 @@ mod tests {
|
||||
"account-a",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&first_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("first enrollment should persist");
|
||||
@@ -739,6 +744,7 @@ mod tests {
|
||||
"account-a",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&second_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("second enrollment should persist");
|
||||
@@ -749,6 +755,7 @@ mod tests {
|
||||
"account-a",
|
||||
/*app_server_client_name*/ None,
|
||||
/*enrollment*/ None,
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("matching enrollment should clear");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod auth;
|
||||
mod client_tracker;
|
||||
mod clients;
|
||||
mod desired_state;
|
||||
mod enroll;
|
||||
mod protocol;
|
||||
mod segment;
|
||||
@@ -8,6 +9,8 @@ mod websocket;
|
||||
|
||||
use self::auth::load_remote_control_auth;
|
||||
use self::auth::recover_remote_control_auth;
|
||||
use self::desired_state::RemoteControlDesiredState;
|
||||
use self::desired_state::acquire_persistence_lock;
|
||||
use self::enroll::RemoteControlEnrollment;
|
||||
use self::enroll::enroll_remote_control_server;
|
||||
use self::enroll::load_persisted_remote_control_enrollment;
|
||||
@@ -63,6 +66,26 @@ pub struct RemoteControlStartConfig {
|
||||
pub installation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteControlStartupMode {
|
||||
ResolvePersisted,
|
||||
DisabledEphemeral,
|
||||
EnabledEphemeral,
|
||||
}
|
||||
|
||||
/// Internal marker used by the daemon to disable remote control without requiring a new CLI flag.
|
||||
pub const REMOTE_CONTROL_DISABLED_ENV_VAR: &str =
|
||||
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
|
||||
|
||||
/// Reads and removes the daemon's internal disabled-start marker before worker threads start.
|
||||
pub fn take_remote_control_disabled_env() -> bool {
|
||||
let disabled =
|
||||
std::env::var_os(REMOTE_CONTROL_DISABLED_ENV_VAR).is_some_and(|value| value == "1");
|
||||
// SAFETY: app-server calls this synchronously at process startup, before spawning threads.
|
||||
unsafe { std::env::remove_var(REMOTE_CONTROL_DISABLED_ENV_VAR) };
|
||||
disabled
|
||||
}
|
||||
|
||||
pub(super) struct QueuedServerEnvelope {
|
||||
pub(super) event: ServerEvent,
|
||||
pub(super) client_id: ClientId,
|
||||
@@ -72,9 +95,10 @@ pub(super) struct QueuedServerEnvelope {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteControlHandle {
|
||||
enabled_tx: Arc<watch::Sender<bool>>,
|
||||
desired_state_tx: Arc<watch::Sender<RemoteControlDesiredState>>,
|
||||
desired_state_rpc_lock: Arc<Semaphore>,
|
||||
desired_state_persistence_lock: Arc<Semaphore>,
|
||||
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
|
||||
state_db_available: bool,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
remote_control_url: String,
|
||||
current_enrollment: CurrentRemoteControlEnrollment,
|
||||
@@ -83,11 +107,17 @@ pub struct RemoteControlHandle {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
// Pairing and websocket connect share one selected server so they cannot enroll or clear
|
||||
// Pairing and websocket connect share one selected server so they cannot enroll or replace
|
||||
// different persisted rows while either path is awaiting backend I/O.
|
||||
type CurrentRemoteControlEnrollment = Arc<RemoteControlEnrollmentState>;
|
||||
type RemoteControlPairingPersistenceKey = watch::Sender<Option<String>>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RemoteControlEnrollmentSelection {
|
||||
ReuseOrCreate,
|
||||
ReplaceExisting,
|
||||
}
|
||||
|
||||
struct RemoteControlEnrollmentState {
|
||||
enrollment: StdMutex<Option<RemoteControlEnrollment>>,
|
||||
lock: Semaphore,
|
||||
@@ -166,23 +196,45 @@ impl fmt::Display for RemoteControlUnavailable {
|
||||
impl Error for RemoteControlUnavailable {}
|
||||
|
||||
impl RemoteControlHandle {
|
||||
pub fn enable(
|
||||
pub fn enable_ephemeral(
|
||||
&self,
|
||||
) -> Result<RemoteControlStatusChangedNotification, RemoteControlUnavailable> {
|
||||
if !self.state_db_available {
|
||||
self.enable_with_preference(/*persistence_preference*/ None)
|
||||
}
|
||||
|
||||
fn enable_with_preference(
|
||||
&self,
|
||||
persistence_preference: Option<bool>,
|
||||
) -> Result<RemoteControlStatusChangedNotification, RemoteControlUnavailable> {
|
||||
if self.state_db.is_none() {
|
||||
warn!("remote control cannot be enabled because sqlite state db is unavailable");
|
||||
return Err(RemoteControlUnavailable);
|
||||
}
|
||||
|
||||
let enabled_changed = self.enabled_tx.send_if_modified(|state| {
|
||||
let changed = !*state;
|
||||
*state = true;
|
||||
let mut effective_persistence_preference = persistence_preference;
|
||||
let desired_state_changed = self.desired_state_tx.send_if_modified(|state| {
|
||||
if effective_persistence_preference.is_none()
|
||||
&& matches!(
|
||||
*state,
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true)
|
||||
}
|
||||
)
|
||||
{
|
||||
effective_persistence_preference = Some(true);
|
||||
}
|
||||
let next_state = RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: effective_persistence_preference,
|
||||
};
|
||||
let changed = *state != next_state;
|
||||
*state = next_state;
|
||||
changed
|
||||
});
|
||||
|
||||
let status = self.status();
|
||||
info!(
|
||||
enabled_changed,
|
||||
desired_state_changed,
|
||||
?effective_persistence_preference,
|
||||
current_status = ?status.status,
|
||||
environment_id = ?status.environment_id,
|
||||
installation_id = %status.installation_id,
|
||||
@@ -199,15 +251,43 @@ impl RemoteControlHandle {
|
||||
Ok(self.publish_status(RemoteControlConnectionStatus::Connecting))
|
||||
}
|
||||
|
||||
pub fn disable(&self) -> RemoteControlStatusChangedNotification {
|
||||
let enabled_changed = self.enabled_tx.send_if_modified(|state| {
|
||||
let changed = *state;
|
||||
*state = false;
|
||||
pub async fn disable(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<RemoteControlStatusChangedNotification> {
|
||||
let _transition = self
|
||||
.desired_state_rpc_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
let _persistence = acquire_persistence_lock(&self.desired_state_persistence_lock).await;
|
||||
self.persist_preference(
|
||||
app_server_client_name,
|
||||
/*remote_control_enabled*/ false,
|
||||
)
|
||||
.await?;
|
||||
Ok(self.transition_disabled())
|
||||
}
|
||||
|
||||
pub async fn disable_ephemeral(&self) -> RemoteControlStatusChangedNotification {
|
||||
let _transition = self
|
||||
.desired_state_rpc_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
let _persistence = acquire_persistence_lock(&self.desired_state_persistence_lock).await;
|
||||
self.transition_disabled()
|
||||
}
|
||||
|
||||
fn transition_disabled(&self) -> RemoteControlStatusChangedNotification {
|
||||
let desired_state_changed = self.desired_state_tx.send_if_modified(|state| {
|
||||
let changed = *state != RemoteControlDesiredState::Disabled;
|
||||
*state = RemoteControlDesiredState::Disabled;
|
||||
changed
|
||||
});
|
||||
let status = self.status();
|
||||
info!(
|
||||
enabled_changed,
|
||||
desired_state_changed,
|
||||
current_status = ?status.status,
|
||||
environment_id = ?status.environment_id,
|
||||
installation_id = %status.installation_id,
|
||||
@@ -217,6 +297,30 @@ impl RemoteControlHandle {
|
||||
self.publish_status(RemoteControlConnectionStatus::Disabled)
|
||||
}
|
||||
|
||||
async fn persist_preference(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
remote_control_enabled: bool,
|
||||
) -> io::Result<()> {
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, RemoteControlUnavailable))?;
|
||||
let auth = load_remote_control_auth(&self.auth_manager).await?;
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?;
|
||||
state_db
|
||||
.set_remote_control_enabled(
|
||||
&remote_control_target.websocket_url,
|
||||
&auth.account_id,
|
||||
app_server_client_name.as_deref(),
|
||||
remote_control_enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RemoteControlStatusChangedNotification {
|
||||
self.status_tx.borrow().clone()
|
||||
}
|
||||
@@ -230,6 +334,9 @@ impl RemoteControlHandle {
|
||||
params: RemoteControlPairingStartParams,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> io::Result<RemoteControlPairingStartResponse> {
|
||||
if !self.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let mut auth = load_remote_control_auth(&self.auth_manager)
|
||||
.await
|
||||
.map_err(|_| pairing_unavailable_error())?;
|
||||
@@ -245,19 +352,35 @@ impl RemoteControlHandle {
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
RemoteControlEnrollmentSelection::ReuseOrCreate,
|
||||
)
|
||||
.await?;
|
||||
if enrollment.should_refresh_server_token() {
|
||||
refresh_pairing_enrollment(
|
||||
let refresh_result = refresh_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&mut enrollment,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
if refresh_result
|
||||
.as_ref()
|
||||
.is_err_and(|err| err.kind() == io::ErrorKind::NotFound)
|
||||
{
|
||||
enrollment = self
|
||||
.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
refresh_result?;
|
||||
}
|
||||
}
|
||||
let pairing_request = || protocol::StartRemoteControlPairingRequest {
|
||||
manual_code: params.manual_code,
|
||||
@@ -267,8 +390,6 @@ impl RemoteControlHandle {
|
||||
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
|
||||
refresh_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
@@ -278,13 +399,6 @@ impl RemoteControlHandle {
|
||||
enrollment.start_pairing(pairing_request()).await
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
clear_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&enrollment,
|
||||
)
|
||||
.await;
|
||||
enrollment = self
|
||||
.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
@@ -292,6 +406,7 @@ impl RemoteControlHandle {
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await?;
|
||||
enrollment.start_pairing(pairing_request()).await
|
||||
@@ -301,13 +416,15 @@ impl RemoteControlHandle {
|
||||
if let Err(err) = &pairing_response {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
clear_pairing_enrollment(
|
||||
self.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&status.server_name,
|
||||
app_server_client_name,
|
||||
&enrollment,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
io::ErrorKind::PermissionDenied => {
|
||||
@@ -323,6 +440,9 @@ impl RemoteControlHandle {
|
||||
if current_auth.account_id != auth.account_id {
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
if !self.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
pairing_response
|
||||
}
|
||||
|
||||
@@ -333,31 +453,86 @@ impl RemoteControlHandle {
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
selection: RemoteControlEnrollmentSelection,
|
||||
) -> io::Result<RemoteControlEnrollment> {
|
||||
if let Some(enrollment) = current_enrollment
|
||||
.as_ref()
|
||||
.filter(|enrollment| enrollment.account_id == auth.account_id)
|
||||
.cloned()
|
||||
{
|
||||
let (enrollment, created) = self
|
||||
.load_or_enroll_server(
|
||||
current_enrollment,
|
||||
auth,
|
||||
installation_id,
|
||||
server_name,
|
||||
app_server_client_name,
|
||||
selection,
|
||||
)
|
||||
.await?;
|
||||
if !created {
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
return Ok(enrollment);
|
||||
}
|
||||
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(pairing_unavailable_error)?;
|
||||
if let Some(mut enrollment) = load_persisted_remote_control_enrollment(
|
||||
let _persistence = acquire_persistence_lock(&self.desired_state_persistence_lock).await;
|
||||
let persistence_preference = match *self.desired_state_tx.borrow() {
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference,
|
||||
} => persistence_preference,
|
||||
RemoteControlDesiredState::Unknown | RemoteControlDesiredState::Disabled => {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
};
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&enrollment.remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
Some(&enrollment),
|
||||
persistence_preference,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
enrollment.server_name = server_name.to_string();
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
return Ok(enrollment);
|
||||
.await?;
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
Ok(enrollment)
|
||||
}
|
||||
|
||||
async fn load_or_enroll_server(
|
||||
&self,
|
||||
current_enrollment: &Option<RemoteControlEnrollment>,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
server_name: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
selection: RemoteControlEnrollmentSelection,
|
||||
) -> io::Result<(RemoteControlEnrollment, bool)> {
|
||||
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
|
||||
match selection {
|
||||
RemoteControlEnrollmentSelection::ReuseOrCreate => {
|
||||
if let Some(enrollment) = current_enrollment
|
||||
.as_ref()
|
||||
.filter(|enrollment| enrollment.account_id == auth.account_id)
|
||||
.cloned()
|
||||
{
|
||||
return Ok((enrollment, false));
|
||||
}
|
||||
|
||||
let state_db = self
|
||||
.state_db
|
||||
.as_deref()
|
||||
.ok_or_else(pairing_unavailable_error)?;
|
||||
if let Some(mut enrollment) = load_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
enrollment.server_name = server_name.to_string();
|
||||
return Ok((enrollment, false));
|
||||
}
|
||||
}
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting => {}
|
||||
}
|
||||
|
||||
let enrollment = enroll_pairing_server(
|
||||
@@ -368,16 +543,7 @@ impl RemoteControlHandle {
|
||||
server_name,
|
||||
)
|
||||
.await?;
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
Some(&enrollment),
|
||||
)
|
||||
.await?;
|
||||
publish_current_enrollment(current_enrollment, &enrollment);
|
||||
Ok(enrollment)
|
||||
Ok((enrollment, true))
|
||||
}
|
||||
|
||||
fn pairing_persistence_key(
|
||||
@@ -398,7 +564,7 @@ impl RemoteControlHandle {
|
||||
&self,
|
||||
params: RemoteControlPairingStatusParams,
|
||||
) -> io::Result<RemoteControlPairingStatusResponse> {
|
||||
if !*self.enabled_tx.borrow() {
|
||||
if !self.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let mut auth = load_remote_control_auth(&self.auth_manager)
|
||||
@@ -412,18 +578,34 @@ impl RemoteControlHandle {
|
||||
.filter(|enrollment| enrollment.account_id == auth.account_id)
|
||||
.cloned()
|
||||
.ok_or_else(pairing_unavailable_error)?;
|
||||
let installation_id = self.status().installation_id;
|
||||
let status = self.status();
|
||||
let installation_id = status.installation_id;
|
||||
let server_name = status.server_name;
|
||||
if enrollment.should_refresh_server_token() {
|
||||
refresh_pairing_enrollment(
|
||||
let refresh_result = refresh_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&mut enrollment,
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
if refresh_result
|
||||
.as_ref()
|
||||
.is_err_and(|err| err.kind() == io::ErrorKind::NotFound)
|
||||
{
|
||||
self.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&server_name,
|
||||
app_server_client_name,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await?;
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
refresh_result?;
|
||||
}
|
||||
let status_code = remote_control_pairing_status_code(¶ms)?;
|
||||
let pairing_status_request =
|
||||
@@ -434,8 +616,6 @@ impl RemoteControlHandle {
|
||||
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
|
||||
refresh_pairing_enrollment(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
app_server_client_name,
|
||||
&self.auth_manager,
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
@@ -449,13 +629,15 @@ impl RemoteControlHandle {
|
||||
if let Err(err) = &pairing_status_response {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => {
|
||||
clear_pairing_enrollment(
|
||||
self.load_or_enroll_pairing_server(
|
||||
&mut current_enrollment,
|
||||
self.state_db.as_deref(),
|
||||
&mut auth,
|
||||
&installation_id,
|
||||
&server_name,
|
||||
app_server_client_name,
|
||||
&enrollment,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
io::ErrorKind::PermissionDenied => {
|
||||
@@ -465,7 +647,7 @@ impl RemoteControlHandle {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !*self.enabled_tx.borrow() {
|
||||
if !self.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(Self::pairing_disabled_error());
|
||||
}
|
||||
let current_auth = load_remote_control_auth(&self.auth_manager)
|
||||
@@ -580,8 +762,6 @@ fn remote_control_pairing_status_code(
|
||||
|
||||
async fn refresh_pairing_enrollment(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
auth: &mut auth::RemoteControlConnectionAuth,
|
||||
installation_id: &str,
|
||||
@@ -589,14 +769,7 @@ async fn refresh_pairing_enrollment(
|
||||
) -> io::Result<()> {
|
||||
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
|
||||
if err.kind() != io::ErrorKind::PermissionDenied {
|
||||
return handle_pairing_refresh_error(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
err,
|
||||
)
|
||||
.await;
|
||||
return Err(err);
|
||||
}
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut auth_change_rx = auth_manager.auth_change_receiver();
|
||||
@@ -609,16 +782,7 @@ async fn refresh_pairing_enrollment(
|
||||
if auth.account_id != enrollment.account_id {
|
||||
return Err(pairing_unavailable_error());
|
||||
}
|
||||
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
|
||||
return handle_pairing_refresh_error(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
err,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
refresh_remote_control_server(auth, installation_id, enrollment).await?
|
||||
}
|
||||
if replace_current_enrollment(current_enrollment, enrollment) {
|
||||
Ok(())
|
||||
@@ -627,52 +791,6 @@ async fn refresh_pairing_enrollment(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_pairing_refresh_error(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
err: io::Error,
|
||||
) -> io::Result<()> {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
clear_pairing_enrollment(
|
||||
current_enrollment,
|
||||
state_db,
|
||||
app_server_client_name,
|
||||
enrollment,
|
||||
)
|
||||
.await;
|
||||
Err(pairing_unavailable_error())
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_pairing_enrollment(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
state_db: Option<&StateRuntime>,
|
||||
app_server_client_name: Option<&str>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) {
|
||||
if !clear_current_enrollment_if_matches(current_enrollment, enrollment) {
|
||||
return;
|
||||
}
|
||||
let Some(state_db) = state_db else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
&enrollment.remote_control_target,
|
||||
&enrollment.account_id,
|
||||
app_server_client_name,
|
||||
/*enrollment*/ None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("failed to clear stale pairing enrollment: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_pairing_server_token(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &mut RemoteControlEnrollment,
|
||||
@@ -729,21 +847,6 @@ fn replace_current_enrollment(
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_current_enrollment_if_matches(
|
||||
current_enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
) -> bool {
|
||||
if current_enrollment
|
||||
.as_ref()
|
||||
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
|
||||
{
|
||||
*current_enrollment = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn same_remote_control_enrollment(
|
||||
left: &RemoteControlEnrollment,
|
||||
right: &RemoteControlEnrollment,
|
||||
@@ -762,11 +865,22 @@ pub async fn start_remote_control(
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
shutdown_token: CancellationToken,
|
||||
app_server_client_name_rx: Option<oneshot::Receiver<String>>,
|
||||
initial_enabled: bool,
|
||||
startup_mode: RemoteControlStartupMode,
|
||||
) -> io::Result<(JoinHandle<()>, RemoteControlHandle)> {
|
||||
let state_db_available = state_db.is_some();
|
||||
let requested_initial_enabled = initial_enabled;
|
||||
let initial_enabled = initial_enabled && state_db_available;
|
||||
let requested_initial_enabled = startup_mode == RemoteControlStartupMode::EnabledEphemeral;
|
||||
let desired_state = if !state_db_available {
|
||||
RemoteControlDesiredState::Disabled
|
||||
} else {
|
||||
match startup_mode {
|
||||
RemoteControlStartupMode::ResolvePersisted => RemoteControlDesiredState::Unknown,
|
||||
RemoteControlStartupMode::DisabledEphemeral => RemoteControlDesiredState::Disabled,
|
||||
RemoteControlStartupMode::EnabledEphemeral => RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
let initial_enabled = desired_state.is_enabled();
|
||||
if requested_initial_enabled && !state_db_available {
|
||||
warn!("remote control disabled because sqlite state db is unavailable");
|
||||
}
|
||||
@@ -776,7 +890,12 @@ pub async fn start_remote_control(
|
||||
None
|
||||
};
|
||||
|
||||
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
|
||||
let (desired_state_tx, _desired_state_rx) = watch::channel(desired_state);
|
||||
let desired_state_tx = Arc::new(desired_state_tx);
|
||||
let desired_state_rpc_lock = Arc::new(Semaphore::new(1));
|
||||
let desired_state_persistence_lock = Arc::new(Semaphore::new(1));
|
||||
let websocket_desired_state_tx = desired_state_tx.clone();
|
||||
let websocket_desired_state_persistence_lock = desired_state_persistence_lock.clone();
|
||||
let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None));
|
||||
let websocket_current_enrollment = current_enrollment.clone();
|
||||
let pairing_persistence_key_required = app_server_client_name_rx.is_some();
|
||||
@@ -804,7 +923,7 @@ pub async fn start_remote_control(
|
||||
installation_id = %installation_id,
|
||||
server_name = %server_name,
|
||||
state_db_available,
|
||||
initial_enabled,
|
||||
?desired_state,
|
||||
"starting app-server remote control websocket task"
|
||||
);
|
||||
let remote_control_url_for_log = remote_control_url.clone();
|
||||
@@ -817,7 +936,7 @@ pub async fn start_remote_control(
|
||||
remote_control_url = %remote_control_url_for_log,
|
||||
installation_id = %installation_id_for_log,
|
||||
server_name = %server_name_for_log,
|
||||
initial_enabled,
|
||||
?desired_state,
|
||||
"app-server remote control websocket task started"
|
||||
);
|
||||
let websocket_task = RemoteControlWebsocket::new(
|
||||
@@ -834,9 +953,10 @@ pub async fn start_remote_control(
|
||||
status_publisher,
|
||||
current_enrollment: websocket_current_enrollment,
|
||||
pairing_persistence_key: websocket_pairing_persistence_key,
|
||||
desired_state_persistence_lock: websocket_desired_state_persistence_lock,
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
websocket_desired_state_tx,
|
||||
)
|
||||
.run(app_server_client_name_rx);
|
||||
match AssertUnwindSafe(websocket_task).catch_unwind().await {
|
||||
@@ -875,9 +995,10 @@ pub async fn start_remote_control(
|
||||
Ok((
|
||||
join_handle,
|
||||
RemoteControlHandle {
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
desired_state_tx,
|
||||
desired_state_rpc_lock,
|
||||
desired_state_persistence_lock,
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available,
|
||||
state_db: handle_state_db,
|
||||
remote_control_url: handle_remote_control_url,
|
||||
current_enrollment,
|
||||
|
||||
@@ -9,6 +9,8 @@ use super::protocol::ClientId;
|
||||
use super::protocol::StreamId;
|
||||
use super::protocol::normalize_remote_control_url;
|
||||
use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION;
|
||||
use super::websocket::RemoteControlWebsocket;
|
||||
use super::websocket::RemoteControlWebsocketConfig;
|
||||
use super::*;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::QueuedOutgoingMessage;
|
||||
@@ -33,6 +35,7 @@ use codex_login::CodexAuth;
|
||||
use codex_login::save_auth;
|
||||
use codex_login::token_data::TokenData;
|
||||
use codex_login::token_data::parse_chatgpt_jwt_claims;
|
||||
use codex_state::RemoteControlEnrollmentRecord;
|
||||
use codex_state::StateRuntime;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
@@ -126,6 +129,137 @@ async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime>
|
||||
.expect("state runtime should initialize")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_start_resolves_persisted_remote_control_preference() {
|
||||
let cases = [
|
||||
("enabled", Some(Some(true))),
|
||||
("disabled", Some(Some(false))),
|
||||
("unset", Some(None)),
|
||||
("missing", None),
|
||||
];
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let remote_control_target = normalize_remote_control_url(TEST_REMOTE_CONTROL_URL)
|
||||
.expect("remote control target should normalize");
|
||||
for (name, stored_preference) in cases {
|
||||
let Some(remote_control_enabled) = stored_preference else {
|
||||
continue;
|
||||
};
|
||||
state_db
|
||||
.upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord {
|
||||
websocket_url: remote_control_target.websocket_url.clone(),
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: Some(name.to_string()),
|
||||
server_id: format!("server-{name}"),
|
||||
environment_id: format!("environment-{name}"),
|
||||
server_name: format!("server-name-{name}"),
|
||||
remote_control_enabled,
|
||||
})
|
||||
.await
|
||||
.expect("enrollment should persist");
|
||||
}
|
||||
let (transport_event_tx, _transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
server_name: test_server_name(),
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
environment_id: None,
|
||||
});
|
||||
let (desired_state_tx, _desired_state_rx) = watch::channel(RemoteControlDesiredState::Unknown);
|
||||
let desired_state_tx = Arc::new(desired_state_tx);
|
||||
let mut websocket = RemoteControlWebsocket::new(
|
||||
RemoteControlWebsocketConfig {
|
||||
remote_control_url: TEST_REMOTE_CONTROL_URL.to_string(),
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
remote_control_target: None,
|
||||
server_name: test_server_name(),
|
||||
},
|
||||
Some(state_db),
|
||||
remote_control_auth_manager(),
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher: RemoteControlStatusPublisher::new(status_tx),
|
||||
current_enrollment: Arc::new(RemoteControlEnrollmentState::new(
|
||||
/*enrollment*/ None,
|
||||
)),
|
||||
pairing_persistence_key: watch::channel(None).0,
|
||||
desired_state_persistence_lock: Arc::new(Semaphore::new(1)),
|
||||
},
|
||||
CancellationToken::new(),
|
||||
desired_state_tx.clone(),
|
||||
);
|
||||
|
||||
for (name, stored_preference) in cases {
|
||||
desired_state_tx.send_replace(RemoteControlDesiredState::Unknown);
|
||||
assert!(websocket.resolve_unknown_desired_state(Some(name)).await);
|
||||
let expected = if stored_preference == Some(Some(true)) {
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
}
|
||||
} else {
|
||||
RemoteControlDesiredState::Disabled
|
||||
};
|
||||
assert_eq!(*desired_state_tx.borrow(), expected, "case {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_disabled_start_ignores_persisted_enable() {
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let remote_control_target = normalize_remote_control_url(TEST_REMOTE_CONTROL_URL)
|
||||
.expect("remote control target should normalize");
|
||||
let enrollment = RemoteControlEnrollmentRecord {
|
||||
websocket_url: remote_control_target.websocket_url,
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: "server-id".to_string(),
|
||||
environment_id: "environment-id".to_string(),
|
||||
server_name: "server-name".to_string(),
|
||||
remote_control_enabled: Some(true),
|
||||
};
|
||||
state_db
|
||||
.upsert_remote_control_enrollment(&enrollment)
|
||||
.await
|
||||
.expect("enrollment should persist");
|
||||
let (transport_event_tx, _transport_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
RemoteControlStartConfig {
|
||||
remote_control_url: TEST_REMOTE_CONTROL_URL.to_string(),
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
},
|
||||
Some(state_db.clone()),
|
||||
remote_control_auth_manager(),
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
RemoteControlStartupMode::DisabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start disabled");
|
||||
|
||||
assert_eq!(
|
||||
*remote_handle.desired_state_tx.borrow(),
|
||||
RemoteControlDesiredState::Disabled
|
||||
);
|
||||
assert_eq!(
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&enrollment.websocket_url,
|
||||
&enrollment.account_id,
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("enrollment should load"),
|
||||
Some(enrollment)
|
||||
);
|
||||
|
||||
shutdown_token.cancel();
|
||||
remote_task.await.expect("remote control task should join");
|
||||
}
|
||||
|
||||
fn remote_control_url_for_listener(listener: &TcpListener) -> String {
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
@@ -141,7 +275,10 @@ fn remote_control_handle_with_current_enrollment(
|
||||
remote_control_url: &str,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> RemoteControlHandle {
|
||||
let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ true);
|
||||
let (desired_state_tx, _desired_state_rx) =
|
||||
watch::channel(RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: None,
|
||||
});
|
||||
let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
server_name: test_server_name(),
|
||||
@@ -165,9 +302,10 @@ fn remote_control_handle_with_current_enrollment(
|
||||
},
|
||||
)));
|
||||
RemoteControlHandle {
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
desired_state_tx: Arc::new(desired_state_tx),
|
||||
desired_state_rpc_lock: Arc::new(Semaphore::new(1)),
|
||||
desired_state_persistence_lock: Arc::new(Semaphore::new(1)),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available: true,
|
||||
state_db: None,
|
||||
remote_control_url: remote_control_url.to_string(),
|
||||
current_enrollment,
|
||||
@@ -177,6 +315,44 @@ fn remote_control_handle_with_current_enrollment(
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ephemeral_enable_preserves_durable_preference() {
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let mut remote_handle = remote_control_handle_with_current_enrollment(
|
||||
TEST_REMOTE_CONTROL_URL,
|
||||
remote_control_auth_manager(),
|
||||
);
|
||||
remote_handle.state_db = Some(remote_control_state_runtime(&codex_home).await);
|
||||
remote_handle
|
||||
.desired_state_tx
|
||||
.send_replace(RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
});
|
||||
|
||||
remote_handle
|
||||
.enable_ephemeral()
|
||||
.expect("ephemeral enable should succeed");
|
||||
assert_eq!(
|
||||
*remote_handle.desired_state_tx.borrow(),
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
}
|
||||
);
|
||||
|
||||
remote_handle
|
||||
.desired_state_tx
|
||||
.send_replace(RemoteControlDesiredState::Disabled);
|
||||
remote_handle
|
||||
.enable_ephemeral()
|
||||
.expect("ephemeral enable should succeed");
|
||||
assert_eq!(
|
||||
*remote_handle.desired_state_tx.borrow(),
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn remote_control_server_token_response(
|
||||
server_id: &str,
|
||||
environment_id: &str,
|
||||
@@ -242,7 +418,10 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let remote_control_url = remote_control_url_for_listener(&listener);
|
||||
let remote_control_target = normalize_remote_control_url(&remote_control_url)
|
||||
.expect("remote control target should normalize");
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let (transport_event_tx, mut transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
@@ -251,12 +430,12 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
remote_control_url,
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
},
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
Some(state_db.clone()),
|
||||
remote_control_auth_manager(),
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -276,6 +455,16 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
)
|
||||
.await;
|
||||
let mut websocket = accept_remote_control_connection(&listener).await;
|
||||
let enrollment = state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("new enrollment should load")
|
||||
.expect("new enrollment should exist");
|
||||
assert_eq!(enrollment.remote_control_enabled, None);
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
@@ -539,7 +728,7 @@ async fn remote_control_transport_reconnects_after_disconnect() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -640,7 +829,7 @@ async fn remote_control_transport_refreshes_server_token_after_websocket_unautho
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -720,7 +909,7 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled()
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ false,
|
||||
RemoteControlStartupMode::ResolvePersisted,
|
||||
)
|
||||
.await
|
||||
.expect("disabled remote control should not validate the URL at startup");
|
||||
@@ -759,7 +948,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start before ChatGPT auth is available");
|
||||
@@ -794,7 +983,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start disabled without sqlite state db");
|
||||
@@ -814,7 +1003,9 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
|
||||
.expect_err("remote control should not connect without sqlite state db");
|
||||
|
||||
assert_eq!(
|
||||
remote_handle.enable().expect_err("enable should fail"),
|
||||
remote_handle
|
||||
.enable_ephemeral()
|
||||
.expect_err("enable should fail"),
|
||||
super::RemoteControlUnavailable
|
||||
);
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
@@ -851,7 +1042,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -884,7 +1075,10 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
remote_handle.disable(),
|
||||
remote_handle
|
||||
.disable(Some("rpc-client"))
|
||||
.await
|
||||
.expect("disable should succeed"),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
server_name: test_server_name(),
|
||||
@@ -910,12 +1104,15 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
|
||||
.expect_err("disabled remote control should not reconnect");
|
||||
|
||||
assert_eq!(
|
||||
remote_handle.enable().expect("enable should succeed"),
|
||||
remote_handle
|
||||
.enable(Some("rpc-client"))
|
||||
.await
|
||||
.expect("enable should succeed"),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
server_name: test_server_name(),
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
environment_id: None,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
}
|
||||
);
|
||||
expect_remote_control_status_snapshot(
|
||||
@@ -924,7 +1121,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
server_name: test_server_name(),
|
||||
installation_id: TEST_INSTALLATION_ID.to_string(),
|
||||
environment_id: None,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -964,7 +1161,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1146,7 +1343,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1376,6 +1573,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&persisted_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("persisted enrollment should save");
|
||||
@@ -1393,7 +1591,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1482,6 +1680,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
|
||||
"account_id",
|
||||
Some(app_server_client_name),
|
||||
Some(&persisted_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("persisted enrollment should save");
|
||||
@@ -1500,7 +1699,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
Some(app_server_client_name_rx),
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1581,7 +1780,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start before account id is available");
|
||||
@@ -1625,6 +1824,123 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
|
||||
let _ = remote_task.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preference() {
|
||||
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");
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json(Some("account_a")),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("account A auth should save");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
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 remote_control_target =
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let enrollment = RemoteControlEnrollment {
|
||||
remote_control_target: remote_control_target.clone(),
|
||||
account_id: "account_a".to_string(),
|
||||
environment_id: "env_a".to_string(),
|
||||
server_id: "srv_e_a".to_string(),
|
||||
server_name: "server-a".to_string(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_a",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&enrollment),
|
||||
/*remote_control_enabled*/ Some(true),
|
||||
)
|
||||
.await
|
||||
.expect("account A enrollment should save");
|
||||
|
||||
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(state_db.clone()),
|
||||
auth_manager.clone(),
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
RemoteControlStartupMode::ResolvePersisted,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
|
||||
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(
|
||||
&enrollment.server_id,
|
||||
&enrollment.environment_id,
|
||||
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let (_handshake_request, mut websocket) =
|
||||
accept_remote_control_backend_connection(&listener).await;
|
||||
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json(Some("account_b")),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("account B auth should save");
|
||||
auth_manager.reload().await;
|
||||
websocket
|
||||
.close(None)
|
||||
.await
|
||||
.expect("backend websocket should close");
|
||||
|
||||
let mut desired_state_rx = remote_handle.desired_state_tx.subscribe();
|
||||
timeout(
|
||||
Duration::from_secs(1),
|
||||
desired_state_rx.wait_for(|state| *state == RemoteControlDesiredState::Disabled),
|
||||
)
|
||||
.await
|
||||
.expect("account B missing preference should disable remote control")
|
||||
.expect("desired state channel should stay open");
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
.await
|
||||
.expect_err("disabled account B should not enroll");
|
||||
assert_eq!(
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_b",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("account B enrollment should load"),
|
||||
None
|
||||
);
|
||||
|
||||
shutdown_token.cancel();
|
||||
let _ = remote_task.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollment() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -1660,6 +1976,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
/*remote_control_enabled*/ Some(true),
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
@@ -1677,7 +1994,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::ResolvePersisted,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1695,12 +2012,6 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
)
|
||||
.await;
|
||||
respond_with_status(refresh_request.stream, "404 Not Found", "").await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
/*expected_environment_id*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -1729,15 +2040,23 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
|
||||
Some(&refreshed_enrollment.server_id)
|
||||
);
|
||||
assert_eq!(
|
||||
load_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(refreshed_enrollment)
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(RemoteControlEnrollmentRecord {
|
||||
websocket_url: remote_control_target.websocket_url.clone(),
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: refreshed_enrollment.server_id.clone(),
|
||||
environment_id: refreshed_enrollment.environment_id.clone(),
|
||||
server_name: refreshed_enrollment.server_name.clone(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
);
|
||||
|
||||
shutdown_token.cancel();
|
||||
@@ -1779,6 +2098,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
/*remote_control_enabled*/ Some(true),
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
@@ -1796,7 +2116,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::ResolvePersisted,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
@@ -1838,12 +2158,6 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
|
||||
&json!({"detail": "Remote app server not found"}).to_string(),
|
||||
)
|
||||
.await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
/*expected_environment_id*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -1872,15 +2186,125 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
|
||||
Some(&refreshed_enrollment.server_id)
|
||||
);
|
||||
assert_eq!(
|
||||
load_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(RemoteControlEnrollmentRecord {
|
||||
websocket_url: remote_control_target.websocket_url.clone(),
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: refreshed_enrollment.server_id.clone(),
|
||||
environment_id: refreshed_enrollment.environment_id.clone(),
|
||||
server_name: refreshed_enrollment.server_name.clone(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
);
|
||||
|
||||
shutdown_token.cancel();
|
||||
let _ = remote_task.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_fails() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(refreshed_enrollment)
|
||||
.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 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 stale_enrollment = RemoteControlEnrollment {
|
||||
remote_control_target: remote_control_target.clone(),
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_stale".to_string(),
|
||||
server_id: "srv_e_stale".to_string(),
|
||||
server_name: test_server_name(),
|
||||
remote_control_token: None,
|
||||
expires_at: None,
|
||||
};
|
||||
update_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
/*remote_control_enabled*/ Some(true),
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
|
||||
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(state_db.clone()),
|
||||
remote_control_auth_manager_with_home(&codex_home),
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
RemoteControlStartupMode::ResolvePersisted,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
|
||||
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_status(refresh_request.stream, "404 Not Found", "").await;
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
enroll_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
respond_with_status(enroll_request.stream, "500 Internal Server Error", "failed").await;
|
||||
|
||||
let retry_refresh_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
retry_refresh_request.request_line,
|
||||
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
|
||||
);
|
||||
respond_with_status(
|
||||
retry_refresh_request.stream,
|
||||
"500 Internal Server Error",
|
||||
"failed",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
*remote_handle.current_enrollment.lock().await,
|
||||
Some(stale_enrollment.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should load"),
|
||||
Some(RemoteControlEnrollmentRecord {
|
||||
websocket_url: remote_control_target.websocket_url,
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: stale_enrollment.server_id,
|
||||
environment_id: stale_enrollment.environment_id,
|
||||
server_name: stale_enrollment.server_name,
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
);
|
||||
|
||||
shutdown_token.cancel();
|
||||
@@ -1912,6 +2336,7 @@ async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_4
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
/*remote_control_enabled*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
@@ -1929,7 +2354,7 @@ async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_4
|
||||
transport_event_tx,
|
||||
shutdown_token.clone(),
|
||||
/*app_server_client_name_rx*/ None,
|
||||
/*initial_enabled*/ true,
|
||||
RemoteControlStartupMode::EnabledEphemeral,
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
|
||||
@@ -13,7 +13,7 @@ fn client_management_handle(
|
||||
remote_control_url: String,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> RemoteControlHandle {
|
||||
let (enabled_tx, _enabled_rx) = watch::channel(/*init*/ false);
|
||||
let desired_state_tx = watch::channel(RemoteControlDesiredState::Disabled).0;
|
||||
let (status_tx, _status_rx) = watch::channel(RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
server_name: test_server_name(),
|
||||
@@ -21,9 +21,10 @@ fn client_management_handle(
|
||||
environment_id: None,
|
||||
});
|
||||
RemoteControlHandle {
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
desired_state_tx: Arc::new(desired_state_tx),
|
||||
desired_state_rpc_lock: Arc::new(Semaphore::new(1)),
|
||||
desired_state_persistence_lock: Arc::new(Semaphore::new(1)),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available: false,
|
||||
state_db: None,
|
||||
remote_control_url,
|
||||
current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)),
|
||||
|
||||
@@ -645,7 +645,9 @@ async fn remote_control_handle_disable_keeps_current_enrollment() {
|
||||
remote_control_auth_manager(),
|
||||
);
|
||||
|
||||
remote_handle.disable();
|
||||
remote_handle
|
||||
.desired_state_tx
|
||||
.send_replace(RemoteControlDesiredState::Disabled);
|
||||
assert!(
|
||||
remote_handle.current_enrollment.lock().await.is_some(),
|
||||
"disabled remote control should keep the selected pairing server"
|
||||
@@ -665,7 +667,6 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
|
||||
remote_control_auth_manager_with_home(&codex_home),
|
||||
);
|
||||
remote_handle.state_db = Some(state_db.clone());
|
||||
remote_handle.disable();
|
||||
let stale_enrollment = remote_handle
|
||||
.current_enrollment
|
||||
.lock()
|
||||
@@ -688,9 +689,15 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
Some(&stale_enrollment),
|
||||
/*remote_control_enabled*/ Some(true),
|
||||
)
|
||||
.await
|
||||
.expect("stale enrollment should save");
|
||||
remote_handle
|
||||
.desired_state_tx
|
||||
.send_replace(RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
});
|
||||
let server_refreshed_enrollment = refreshed_enrollment.clone();
|
||||
let server_task = tokio::spawn(async move {
|
||||
let stale_pairing_request = accept_http_request(&listener).await;
|
||||
@@ -761,15 +768,17 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
load_persisted_remote_control_enrollment(
|
||||
Some(state_db.as_ref()),
|
||||
&remote_control_target,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load"),
|
||||
Some(refreshed_enrollment)
|
||||
state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
"account_id",
|
||||
/*app_server_client_name*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("refreshed enrollment should load")
|
||||
.expect("refreshed enrollment should exist")
|
||||
.remote_control_enabled,
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use super::CurrentRemoteControlEnrollment;
|
||||
use super::RemoteControlEnrollmentSelection;
|
||||
use super::RemoteControlPairingPersistenceKey;
|
||||
use super::desired_state::RemoteControlDesiredState;
|
||||
use super::desired_state::acquire_persistence_lock;
|
||||
use super::desired_state::desired_state_from_persisted_enrollment;
|
||||
use super::protocol::ClientEnvelope;
|
||||
use super::protocol::ClientEvent;
|
||||
use super::protocol::ClientId;
|
||||
@@ -45,6 +49,7 @@ use std::io::ErrorKind;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch;
|
||||
@@ -264,7 +269,9 @@ pub(crate) struct RemoteControlWebsocket {
|
||||
state: Arc<Mutex<WebsocketState>>,
|
||||
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
|
||||
used_rx: watch::Receiver<usize>,
|
||||
enabled_rx: watch::Receiver<bool>,
|
||||
desired_state_tx: Arc<watch::Sender<RemoteControlDesiredState>>,
|
||||
desired_state_rx: watch::Receiver<RemoteControlDesiredState>,
|
||||
desired_state_persistence_lock: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
pub(crate) struct RemoteControlWebsocketConfig {
|
||||
@@ -280,6 +287,11 @@ pub(super) struct RemoteControlAuthContext<'a> {
|
||||
auth_change_rx: &'a mut watch::Receiver<u64>,
|
||||
}
|
||||
|
||||
struct RemoteControlEnrollmentAuthContext<'a, 'b> {
|
||||
auth: &'a RemoteControlConnectionAuth,
|
||||
recovery: &'a mut RemoteControlAuthContext<'b>,
|
||||
}
|
||||
|
||||
enum ConnectOutcome {
|
||||
Connected(Box<WebSocketStream<MaybeTlsStream<TcpStream>>>),
|
||||
Disabled,
|
||||
@@ -299,6 +311,7 @@ pub(super) struct RemoteControlChannels {
|
||||
pub(super) status_publisher: RemoteControlStatusPublisher,
|
||||
pub(super) current_enrollment: CurrentRemoteControlEnrollment,
|
||||
pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey,
|
||||
pub(super) desired_state_persistence_lock: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -341,7 +354,7 @@ impl RemoteControlStatusPublisher {
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_environment_id(&self, environment_id: Option<String>) {
|
||||
pub(super) fn publish_environment_id(&self, environment_id: Option<String>) {
|
||||
let mut status_change = None;
|
||||
self.tx.send_if_modified(|status| {
|
||||
if status.status == RemoteControlConnectionStatus::Disabled {
|
||||
@@ -380,6 +393,8 @@ pub(super) struct RemoteControlConnectOptions<'a> {
|
||||
server_name: &'a str,
|
||||
subscribe_cursor: Option<&'a str>,
|
||||
app_server_client_name: Option<&'a str>,
|
||||
desired_state_tx: &'a watch::Sender<RemoteControlDesiredState>,
|
||||
desired_state_persistence_lock: &'a Semaphore,
|
||||
}
|
||||
|
||||
impl RemoteControlWebsocket {
|
||||
@@ -389,7 +404,7 @@ impl RemoteControlWebsocket {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
channels: RemoteControlChannels,
|
||||
shutdown_token: CancellationToken,
|
||||
enabled_rx: watch::Receiver<bool>,
|
||||
desired_state_tx: Arc<watch::Sender<RemoteControlDesiredState>>,
|
||||
) -> Self {
|
||||
let shutdown_token = shutdown_token.child_token();
|
||||
let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY);
|
||||
@@ -402,6 +417,7 @@ impl RemoteControlWebsocket {
|
||||
let auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let auth_change_rx = auth_manager.auth_change_receiver();
|
||||
|
||||
let desired_state_rx = desired_state_tx.subscribe();
|
||||
Self {
|
||||
remote_control_url: config.remote_control_url,
|
||||
installation_id: config.installation_id,
|
||||
@@ -426,7 +442,9 @@ impl RemoteControlWebsocket {
|
||||
})),
|
||||
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
|
||||
used_rx,
|
||||
enabled_rx,
|
||||
desired_state_tx,
|
||||
desired_state_rx,
|
||||
desired_state_persistence_lock: channels.desired_state_persistence_lock,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +460,7 @@ impl RemoteControlWebsocket {
|
||||
remote_control_url = %self.remote_control_url,
|
||||
installation_id = %self.installation_id,
|
||||
server_name = %self.server_name,
|
||||
initial_enabled = *self.enabled_rx.borrow(),
|
||||
initial_desired_state = ?*self.desired_state_rx.borrow(),
|
||||
"app-server remote control websocket loop started"
|
||||
);
|
||||
let app_server_client_name = match self
|
||||
@@ -464,6 +482,16 @@ impl RemoteControlWebsocket {
|
||||
};
|
||||
self.pairing_persistence_key
|
||||
.send_replace(app_server_client_name.clone());
|
||||
if matches!(
|
||||
*self.desired_state_rx.borrow(),
|
||||
RemoteControlDesiredState::Unknown
|
||||
) && !self
|
||||
.resolve_unknown_desired_state(app_server_client_name.as_deref())
|
||||
.await
|
||||
{
|
||||
self.client_tracker.lock().await.shutdown().await;
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
if !self.wait_until_enabled().await {
|
||||
@@ -513,7 +541,7 @@ impl RemoteControlWebsocket {
|
||||
connection_end_reason = ?connection_end_reason,
|
||||
current_status = ?status.status,
|
||||
environment_id = ?status.environment_id,
|
||||
enabled = *self.enabled_rx.borrow(),
|
||||
desired_state = ?*self.desired_state_rx.borrow(),
|
||||
"app-server remote control websocket connection cycle ended"
|
||||
);
|
||||
}
|
||||
@@ -546,10 +574,96 @@ impl RemoteControlWebsocket {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_unknown_desired_state(
|
||||
&mut self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> bool {
|
||||
let remote_control_target = match super::protocol::normalize_remote_control_url(
|
||||
&self.remote_control_url,
|
||||
) {
|
||||
Ok(remote_control_target) => remote_control_target,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"remote control preference cannot be resolved because the URL is invalid: {err}"
|
||||
);
|
||||
self.transition_unknown_to(RemoteControlDesiredState::Disabled);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
self.remote_control_target = Some(remote_control_target.clone());
|
||||
let Some(state_db) = self.state_db.clone() else {
|
||||
self.transition_unknown_to(RemoteControlDesiredState::Disabled);
|
||||
return true;
|
||||
};
|
||||
|
||||
loop {
|
||||
if !matches!(
|
||||
*self.desired_state_rx.borrow(),
|
||||
RemoteControlDesiredState::Unknown
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let auth = match load_remote_control_auth(&self.auth_manager).await {
|
||||
Ok(auth) => auth,
|
||||
Err(err) => {
|
||||
info!(
|
||||
error = %err,
|
||||
"waiting to resolve remote control preference until authentication is available"
|
||||
);
|
||||
if !self.wait_for_preference_resolution_retry().await {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let enrollment = match state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(enrollment) => enrollment,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"failed to resolve persisted remote control preference; retrying"
|
||||
);
|
||||
if !self.wait_for_preference_resolution_retry().await {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let desired_state = desired_state_from_persisted_enrollment(enrollment);
|
||||
self.transition_unknown_to(desired_state);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
fn transition_unknown_to(&self, desired_state: RemoteControlDesiredState) {
|
||||
self.desired_state_tx.send_if_modified(|state| {
|
||||
if !matches!(*state, RemoteControlDesiredState::Unknown) {
|
||||
return false;
|
||||
}
|
||||
*state = desired_state;
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
async fn wait_for_preference_resolution_retry(&mut self) -> bool {
|
||||
tokio::select! {
|
||||
_ = self.shutdown_token.cancelled() => false,
|
||||
changed = self.desired_state_rx.changed() => changed.is_ok(),
|
||||
_ = tokio::time::sleep(REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL) => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_until_enabled(&mut self) -> bool {
|
||||
tokio::select! {
|
||||
_ = self.shutdown_token.cancelled() => false,
|
||||
enabled = self.enabled_rx.wait_for(|enabled| *enabled) => enabled.is_ok(),
|
||||
desired_state = self.desired_state_rx.wait_for(|state| state.is_enabled()) => desired_state.is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,7 +687,7 @@ impl RemoteControlWebsocket {
|
||||
warn!("remote control is enabled but the URL is invalid: {err}");
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
|
||||
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
changed = self.desired_state_rx.wait_for(|state| !state.is_enabled()) => {
|
||||
if changed.is_err() {
|
||||
return ConnectOutcome::Shutdown;
|
||||
}
|
||||
@@ -604,15 +718,18 @@ impl RemoteControlWebsocket {
|
||||
server_name: &self.server_name,
|
||||
subscribe_cursor: subscribe_cursor.as_deref(),
|
||||
app_server_client_name,
|
||||
desired_state_tx: &self.desired_state_tx,
|
||||
desired_state_persistence_lock: &self.desired_state_persistence_lock,
|
||||
};
|
||||
let auth_context = RemoteControlAuthContext {
|
||||
auth_manager: &self.auth_manager,
|
||||
auth_recovery: &mut self.auth_recovery,
|
||||
auth_change_rx: &mut self.auth_change_rx,
|
||||
};
|
||||
let mut disabled_rx = self.desired_state_rx.clone();
|
||||
let connect_result = tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
|
||||
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
changed = disabled_rx.wait_for(|state| !state.is_enabled()) => {
|
||||
if changed.is_err() {
|
||||
return ConnectOutcome::Shutdown;
|
||||
}
|
||||
@@ -633,7 +750,7 @@ impl RemoteControlWebsocket {
|
||||
|
||||
match connect_result {
|
||||
Ok((websocket_connection, response)) => {
|
||||
if !*self.enabled_rx.borrow() {
|
||||
if !self.desired_state_rx.borrow().is_enabled() {
|
||||
return ConnectOutcome::Disabled;
|
||||
}
|
||||
self.reconnect_attempt = 0;
|
||||
@@ -654,7 +771,7 @@ impl RemoteControlWebsocket {
|
||||
return ConnectOutcome::Connected(Box::new(websocket_connection));
|
||||
}
|
||||
Err(err) => {
|
||||
if !*self.enabled_rx.borrow() {
|
||||
if !self.desired_state_rx.borrow().is_enabled() {
|
||||
return ConnectOutcome::Disabled;
|
||||
}
|
||||
let reconnect_delay = if err.kind() == ErrorKind::WouldBlock {
|
||||
@@ -691,7 +808,7 @@ impl RemoteControlWebsocket {
|
||||
};
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
|
||||
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
changed = self.desired_state_rx.wait_for(|state| !state.is_enabled()) => {
|
||||
if changed.is_err() {
|
||||
return ConnectOutcome::Shutdown;
|
||||
}
|
||||
@@ -736,10 +853,10 @@ impl RemoteControlWebsocket {
|
||||
shutdown_token.clone(),
|
||||
));
|
||||
|
||||
let mut enabled_rx = self.enabled_rx.clone();
|
||||
let mut desired_state_rx = self.desired_state_rx.clone();
|
||||
let connection_end_reason = tokio::select! {
|
||||
_ = shutdown_token.cancelled() => ConnectionEndReason::Shutdown,
|
||||
changed = enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
changed = desired_state_rx.wait_for(|state| !state.is_enabled()) => {
|
||||
if changed.is_ok() {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Disabled);
|
||||
@@ -1253,22 +1370,25 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
if websocket_response_reports_missing_remote_app_server(response) =>
|
||||
{
|
||||
info!(
|
||||
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
"remote control websocket returned HTTP 404; replacing stale enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}",
|
||||
remote_control_target.websocket_url,
|
||||
auth.account_id,
|
||||
enrollment.server_id,
|
||||
enrollment.environment_id
|
||||
);
|
||||
clear_remote_control_enrollment_if_matches(
|
||||
replace_remote_control_enrollment_if_matches(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
RemoteControlEnrollmentAuthContext {
|
||||
auth: &auth,
|
||||
recovery: &mut auth_context,
|
||||
},
|
||||
current_enrollment,
|
||||
&enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
}
|
||||
tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
|
||||
let response_body = response
|
||||
@@ -1337,6 +1457,14 @@ async fn prepare_remote_control_enrollment(
|
||||
};
|
||||
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) {
|
||||
resolve_desired_state_after_account_change(
|
||||
state_db,
|
||||
remote_control_target,
|
||||
auth_context.auth_manager,
|
||||
&auth.account_id,
|
||||
connect_options,
|
||||
)
|
||||
.await?;
|
||||
info!(
|
||||
"clearing in-memory remote control enrollment because account id changed: websocket_url={}, previous_account_id={:?}, current_account_id={:?}",
|
||||
remote_control_target.websocket_url,
|
||||
@@ -1347,6 +1475,12 @@ async fn prepare_remote_control_enrollment(
|
||||
);
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
if !connect_options.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::Interrupted,
|
||||
"remote control disabled after account changed",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(enrollment) = enrollment.as_mut() {
|
||||
enrollment.remote_control_target = remote_control_target.clone();
|
||||
@@ -1373,14 +1507,17 @@ async fn prepare_remote_control_enrollment(
|
||||
});
|
||||
}
|
||||
|
||||
enroll_remote_control_server_if_missing(
|
||||
enroll_and_persist_remote_control_server(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
auth_context,
|
||||
RemoteControlEnrollmentAuthContext {
|
||||
auth: &auth,
|
||||
recovery: auth_context,
|
||||
},
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
RemoteControlEnrollmentSelection::ReuseOrCreate,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1412,26 +1549,20 @@ async fn prepare_remote_control_enrollment(
|
||||
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 server refresh returned HTTP 404; replacing stale enrollment: 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(
|
||||
enroll_and_persist_remote_control_server(
|
||||
remote_control_target,
|
||||
state_db,
|
||||
&auth,
|
||||
auth_context,
|
||||
RemoteControlEnrollmentAuthContext {
|
||||
auth: &auth,
|
||||
recovery: auth_context,
|
||||
},
|
||||
enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -1454,6 +1585,51 @@ async fn prepare_remote_control_enrollment(
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
async fn resolve_desired_state_after_account_change(
|
||||
state_db: &StateRuntime,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
account_id: &str,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
) -> io::Result<()> {
|
||||
let durable_enabled = RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: Some(true),
|
||||
};
|
||||
if *connect_options.desired_state_tx.borrow() != durable_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _persistence =
|
||||
acquire_persistence_lock(connect_options.desired_state_persistence_lock).await;
|
||||
if *connect_options.desired_state_tx.borrow() != durable_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let enrollment = state_db
|
||||
.get_remote_control_enrollment(
|
||||
&remote_control_target.websocket_url,
|
||||
account_id,
|
||||
connect_options.app_server_client_name,
|
||||
)
|
||||
.await
|
||||
.map_err(io::Error::other)?;
|
||||
let current_auth = load_remote_control_auth(auth_manager).await?;
|
||||
if current_auth.account_id != account_id {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::WouldBlock,
|
||||
"remote control account changed while resolving persisted preference",
|
||||
));
|
||||
}
|
||||
let resolved_state = desired_state_from_persisted_enrollment(enrollment);
|
||||
connect_options.desired_state_tx.send_if_modified(|state| {
|
||||
if *state != durable_enabled || *state == resolved_state {
|
||||
return false;
|
||||
}
|
||||
*state = resolved_state;
|
||||
true
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn websocket_response_reports_missing_remote_app_server(
|
||||
response: &tungstenite::http::Response<Option<Vec<u8>>>,
|
||||
) -> bool {
|
||||
@@ -1466,57 +1642,38 @@ fn websocket_response_reports_missing_remote_app_server(
|
||||
})
|
||||
}
|
||||
|
||||
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 clear_remote_control_enrollment_if_matches(
|
||||
async fn replace_remote_control_enrollment_if_matches(
|
||||
state_db: Option<&StateRuntime>,
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
auth_context: RemoteControlEnrollmentAuthContext<'_, '_>,
|
||||
current_enrollment: &CurrentRemoteControlEnrollment,
|
||||
enrollment: &RemoteControlEnrollment,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) {
|
||||
) -> io::Result<()> {
|
||||
let Some(state_db) = state_db else {
|
||||
return;
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
"remote control requires sqlite state db",
|
||||
));
|
||||
};
|
||||
let mut current_enrollment = current_enrollment.lock().await;
|
||||
if !current_enrollment
|
||||
.as_ref()
|
||||
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
|
||||
{
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
clear_remote_control_enrollment(
|
||||
state_db,
|
||||
enroll_and_persist_remote_control_server(
|
||||
remote_control_target,
|
||||
account_id,
|
||||
app_server_client_name,
|
||||
state_db,
|
||||
auth_context,
|
||||
&mut current_enrollment,
|
||||
connect_options,
|
||||
status_publisher,
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
}
|
||||
|
||||
async fn clear_remote_control_server_token_if_matches(
|
||||
@@ -1534,26 +1691,39 @@ async fn clear_remote_control_server_token_if_matches(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enroll_remote_control_server_if_missing(
|
||||
async fn enroll_and_persist_remote_control_server(
|
||||
remote_control_target: &RemoteControlTarget,
|
||||
state_db: &StateRuntime,
|
||||
auth: &RemoteControlConnectionAuth,
|
||||
auth_context: &mut RemoteControlAuthContext<'_>,
|
||||
auth_context: RemoteControlEnrollmentAuthContext<'_, '_>,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
selection: RemoteControlEnrollmentSelection,
|
||||
) -> io::Result<()> {
|
||||
if enrollment.is_some() {
|
||||
return Ok(());
|
||||
match selection {
|
||||
RemoteControlEnrollmentSelection::ReuseOrCreate => {
|
||||
if enrollment.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
RemoteControlEnrollmentSelection::ReplaceExisting => {}
|
||||
}
|
||||
if !connect_options.desired_state_tx.borrow().is_enabled() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::Interrupted,
|
||||
"remote control disabled before enrollment",
|
||||
));
|
||||
}
|
||||
|
||||
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
|
||||
remote_control_target.websocket_url,
|
||||
remote_control_target.enroll_url,
|
||||
auth_context.auth.account_id
|
||||
);
|
||||
let new_enrollment = match enroll_remote_control_server(
|
||||
remote_control_target,
|
||||
auth,
|
||||
auth_context.auth,
|
||||
connect_options.installation_id,
|
||||
connect_options.server_name,
|
||||
)
|
||||
@@ -1563,8 +1733,8 @@ async fn enroll_remote_control_server_if_missing(
|
||||
Err(err)
|
||||
if err.kind() == ErrorKind::PermissionDenied
|
||||
&& recover_remote_control_auth(
|
||||
auth_context.auth_recovery,
|
||||
auth_context.auth_change_rx,
|
||||
auth_context.recovery.auth_recovery,
|
||||
auth_context.recovery.auth_change_rx,
|
||||
)
|
||||
.await =>
|
||||
{
|
||||
@@ -1574,12 +1744,26 @@ async fn enroll_remote_control_server_if_missing(
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let _persistence =
|
||||
acquire_persistence_lock(connect_options.desired_state_persistence_lock).await;
|
||||
let persistence_preference = match *connect_options.desired_state_tx.borrow() {
|
||||
RemoteControlDesiredState::Enabled {
|
||||
persistence_preference,
|
||||
} => persistence_preference,
|
||||
RemoteControlDesiredState::Unknown | RemoteControlDesiredState::Disabled => {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::Interrupted,
|
||||
"remote control disabled during enrollment",
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(err) = update_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
&auth_context.auth.account_id,
|
||||
connect_options.app_server_client_name,
|
||||
Some(&new_enrollment),
|
||||
persistence_preference,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1758,6 +1942,13 @@ mod tests {
|
||||
(RemoteControlStatusPublisher::new(status_tx), status_rx)
|
||||
}
|
||||
|
||||
fn enabled_desired_state_sender() -> watch::Sender<RemoteControlDesiredState> {
|
||||
watch::channel(RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: None,
|
||||
})
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() {
|
||||
let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64);
|
||||
@@ -1896,6 +2087,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -1960,6 +2153,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2042,6 +2237,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2136,6 +2333,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2201,6 +2400,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2251,6 +2452,8 @@ mod tests {
|
||||
server_name: "test-server",
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
desired_state_tx: &enabled_desired_state_sender(),
|
||||
desired_state_persistence_lock: &Semaphore::new(1),
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
@@ -2288,7 +2491,10 @@ mod tests {
|
||||
drop(transport_event_rx);
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (_enabled_tx, enabled_rx) = watch::channel(true);
|
||||
let (desired_state_tx, _desired_state_rx) =
|
||||
watch::channel(RemoteControlDesiredState::Enabled {
|
||||
persistence_preference: None,
|
||||
});
|
||||
let websocket_task = tokio::spawn({
|
||||
let shutdown_token = shutdown_token.clone();
|
||||
async move {
|
||||
@@ -2306,9 +2512,10 @@ mod tests {
|
||||
status_publisher,
|
||||
current_enrollment: test_current_enrollment(/*enrollment*/ None),
|
||||
pairing_persistence_key: watch::channel(None).0,
|
||||
desired_state_persistence_lock: Arc::new(Semaphore::new(1)),
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
Arc::new(desired_state_tx),
|
||||
)
|
||||
.run(/*app_server_client_name_rx*/ None)
|
||||
.await
|
||||
|
||||
@@ -210,8 +210,8 @@ Example with notification opt-out:
|
||||
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
- `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/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. By default, any missing enrollment is completed before the response and the preference is persisted for the current app-server client scope. Pass `ephemeral: true` to enable remote control only for the current process without changing the persisted preference.
|
||||
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. By default, the disabled preference is persisted for the current app-server client scope. Pass `ephemeral: true` to disable only for the current process without changing the persisted preference. 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/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`.
|
||||
|
||||
@@ -110,10 +110,12 @@ mod transport;
|
||||
pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
|
||||
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
|
||||
pub use crate::transport::AppServerTransport;
|
||||
pub use crate::transport::RemoteControlStartupMode;
|
||||
pub use crate::transport::app_server_control_socket_path;
|
||||
pub use crate::transport::auth::AppServerWebsocketAuthArgs;
|
||||
pub use crate::transport::auth::AppServerWebsocketAuthSettings;
|
||||
pub use crate::transport::auth::WebsocketAuthCliMode;
|
||||
pub use crate::transport::take_remote_control_disabled_env;
|
||||
|
||||
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";
|
||||
const OTEL_SERVICE_NAME: &str = "codex-app-server";
|
||||
@@ -408,7 +410,7 @@ pub enum PluginStartupTasks {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AppServerRuntimeOptions {
|
||||
pub plugin_startup_tasks: PluginStartupTasks,
|
||||
pub remote_control_enabled: bool,
|
||||
pub remote_control_startup_mode: RemoteControlStartupMode,
|
||||
pub install_shutdown_signal_handler: bool,
|
||||
}
|
||||
|
||||
@@ -416,7 +418,7 @@ impl Default for AppServerRuntimeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_startup_tasks: PluginStartupTasks::Start,
|
||||
remote_control_enabled: false,
|
||||
remote_control_startup_mode: RemoteControlStartupMode::ResolvePersisted,
|
||||
install_shutdown_signal_handler: true,
|
||||
}
|
||||
}
|
||||
@@ -717,15 +719,21 @@ pub async fn run_main_with_transport_options(
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
|
||||
|
||||
let remote_control_requested = runtime_options.remote_control_enabled;
|
||||
let remote_control_enabled = remote_control_requested && state_db.is_some();
|
||||
if remote_control_requested && state_db.is_none() {
|
||||
let remote_control_startup_mode = runtime_options.remote_control_startup_mode;
|
||||
let remote_control_explicitly_requested =
|
||||
remote_control_startup_mode == RemoteControlStartupMode::EnabledEphemeral;
|
||||
let remote_control_enabled = remote_control_explicitly_requested && state_db.is_some();
|
||||
if remote_control_explicitly_requested && state_db.is_none() {
|
||||
error!("remote control disabled because sqlite state db is unavailable");
|
||||
}
|
||||
if transport_accept_handles.is_empty() && !remote_control_enabled {
|
||||
let no_local_transport = transport_accept_handles.is_empty();
|
||||
if no_local_transport
|
||||
&& remote_control_startup_mode != RemoteControlStartupMode::ResolvePersisted
|
||||
&& !remote_control_enabled
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
if remote_control_requested && state_db.is_none() {
|
||||
if remote_control_explicitly_requested && state_db.is_none() {
|
||||
"no transport configured; remote control disabled because sqlite state db is unavailable"
|
||||
} else {
|
||||
"no transport configured; use --listen or enable remote control"
|
||||
@@ -743,9 +751,31 @@ pub async fn run_main_with_transport_options(
|
||||
transport_event_tx.clone(),
|
||||
transport_shutdown_token.clone(),
|
||||
app_server_client_name_rx,
|
||||
remote_control_enabled,
|
||||
remote_control_startup_mode,
|
||||
)
|
||||
.await?;
|
||||
if no_local_transport
|
||||
&& remote_control_startup_mode == RemoteControlStartupMode::ResolvePersisted
|
||||
{
|
||||
let persisted_enabled = match remote_control_handle
|
||||
.resolve_persisted_preference(/*app_server_client_name*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(persisted_enabled) => persisted_enabled,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve persisted remote control preference: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if !persisted_enabled {
|
||||
transport_shutdown_token.cancel();
|
||||
let _ = remote_control_accept_handle.await;
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"no transport configured; use --listen or enable remote control",
|
||||
));
|
||||
}
|
||||
}
|
||||
transport_accept_handles.push(remote_control_accept_handle);
|
||||
|
||||
let outbound_handle = tokio::spawn(async move {
|
||||
|
||||
@@ -53,13 +53,14 @@ struct AppServerArgs {
|
||||
#[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)]
|
||||
disable_plugin_startup_tasks_for_tests: bool,
|
||||
|
||||
/// Enable remote control for this app-server process.
|
||||
/// Enable remote control for this app-server process without changing persistence.
|
||||
#[arg(long = "remote-control", hide = true)]
|
||||
remote_control: bool,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
|
||||
let remote_control_disabled = codex_app_server::take_remote_control_disabled_env();
|
||||
arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move {
|
||||
let AppServerArgs {
|
||||
config_overrides,
|
||||
listen,
|
||||
@@ -84,7 +85,12 @@ fn main() -> anyhow::Result<()> {
|
||||
if disable_plugin_startup_tasks_for_tests {
|
||||
runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip;
|
||||
}
|
||||
runtime_options.remote_control_enabled = remote_control;
|
||||
runtime_options.remote_control_startup_mode =
|
||||
match (remote_control, remote_control_disabled) {
|
||||
(true, _) => codex_app_server::RemoteControlStartupMode::EnabledEphemeral,
|
||||
(false, true) => codex_app_server::RemoteControlStartupMode::DisabledEphemeral,
|
||||
(false, false) => codex_app_server::RemoteControlStartupMode::ResolvePersisted,
|
||||
};
|
||||
|
||||
run_main_with_transport_options(
|
||||
arg0_paths,
|
||||
|
||||
@@ -951,13 +951,21 @@ impl MessageProcessor {
|
||||
.experimental_feature_enablement_set(request_id.clone(), params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::RemoteControlEnable { .. } => self
|
||||
ClientRequest::RemoteControlEnable { params, .. } => self
|
||||
.remote_control_processor
|
||||
.enable()
|
||||
.enable(
|
||||
params.is_some_and(|params| params.ephemeral),
|
||||
app_server_client_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::RemoteControlDisable { .. } => self
|
||||
ClientRequest::RemoteControlDisable { params, .. } => self
|
||||
.remote_control_processor
|
||||
.disable()
|
||||
.disable(
|
||||
params.is_some_and(|params| params.ephemeral),
|
||||
app_server_client_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::RemoteControlStatusRead { .. } => self
|
||||
.remote_control_processor
|
||||
|
||||
@@ -28,17 +28,38 @@ impl RemoteControlRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn enable(&self) -> Result<RemoteControlEnableResponse, JSONRPCErrorError> {
|
||||
pub(crate) async fn enable(
|
||||
&self,
|
||||
ephemeral: bool,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<RemoteControlEnableResponse, JSONRPCErrorError> {
|
||||
let handle = self.handle()?;
|
||||
handle
|
||||
.enable()
|
||||
.map(RemoteControlEnableResponse::from)
|
||||
.map_err(map_unavailable)
|
||||
let status = if ephemeral {
|
||||
handle.enable_ephemeral().map_err(map_unavailable)?
|
||||
} else {
|
||||
handle
|
||||
.enable(app_server_client_name)
|
||||
.await
|
||||
.map_err(map_update_error)?
|
||||
};
|
||||
Ok(RemoteControlEnableResponse::from(status))
|
||||
}
|
||||
|
||||
pub(crate) fn disable(&self) -> Result<RemoteControlDisableResponse, JSONRPCErrorError> {
|
||||
pub(crate) async fn disable(
|
||||
&self,
|
||||
ephemeral: bool,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<RemoteControlDisableResponse, JSONRPCErrorError> {
|
||||
let handle = self.handle()?;
|
||||
Ok(RemoteControlDisableResponse::from(handle.disable()))
|
||||
let status = if ephemeral {
|
||||
handle.disable_ephemeral().await
|
||||
} else {
|
||||
handle
|
||||
.disable(app_server_client_name)
|
||||
.await
|
||||
.map_err(map_update_error)?
|
||||
};
|
||||
Ok(RemoteControlDisableResponse::from(status))
|
||||
}
|
||||
|
||||
pub(crate) fn status_read(&self) -> Result<RemoteControlStatusReadResponse, JSONRPCErrorError> {
|
||||
@@ -104,6 +125,14 @@ fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError {
|
||||
invalid_request(err.to_string())
|
||||
}
|
||||
|
||||
fn map_update_error(err: io::Error) -> JSONRPCErrorError {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
invalid_request(err.to_string())
|
||||
} else {
|
||||
internal_error(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
|
||||
if err.kind() == io::ErrorKind::InvalidInput {
|
||||
invalid_request(err.to_string())
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(crate) use codex_app_server_transport::OutgoingMessage;
|
||||
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
|
||||
pub(crate) use codex_app_server_transport::RemoteControlHandle;
|
||||
pub(crate) use codex_app_server_transport::RemoteControlStartConfig;
|
||||
pub use codex_app_server_transport::RemoteControlStartupMode;
|
||||
pub(crate) use codex_app_server_transport::RemoteControlUnavailable;
|
||||
pub(crate) use codex_app_server_transport::TransportEvent;
|
||||
pub(crate) use codex_app_server_transport::acquire_app_server_startup_lock;
|
||||
@@ -31,6 +32,7 @@ pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
|
||||
pub(crate) use codex_app_server_transport::start_remote_control;
|
||||
pub(crate) use codex_app_server_transport::start_stdio_connection;
|
||||
pub(crate) use codex_app_server_transport::start_websocket_acceptor;
|
||||
pub use codex_app_server_transport::take_remote_control_disabled_env;
|
||||
|
||||
pub(crate) struct ConnectionState {
|
||||
pub(crate) outbound_initialized: Arc<AtomicBool>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::process::ExitStatus;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::AtomicI64;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -128,6 +129,10 @@ pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tas
|
||||
const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG";
|
||||
|
||||
impl TestAppServer {
|
||||
pub async fn wait_for_exit(&mut self) -> std::io::Result<ExitStatus> {
|
||||
self.process.wait().await
|
||||
}
|
||||
|
||||
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
|
||||
Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await
|
||||
}
|
||||
@@ -645,12 +650,30 @@ impl TestAppServer {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a runtime-only `remoteControl/enable` JSON-RPC request.
|
||||
pub async fn send_remote_control_ephemeral_enable_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request(
|
||||
"remoteControl/enable",
|
||||
Some(serde_json::json!({ "ephemeral": true })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `remoteControl/disable` JSON-RPC request.
|
||||
pub async fn send_remote_control_disable_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("remoteControl/disable", /*params*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a runtime-only `remoteControl/disable` JSON-RPC request.
|
||||
pub async fn send_remote_control_ephemeral_disable_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request(
|
||||
"remoteControl/disable",
|
||||
Some(serde_json::json!({ "ephemeral": true })),
|
||||
)
|
||||
.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)
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
@@ -24,6 +25,8 @@ use codex_app_server_protocol::RemoteControlPairingStatusResponse;
|
||||
use codex_app_server_protocol::RemoteControlStatusReadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_state::RemoteControlEnrollmentRecord;
|
||||
use codex_state::StateRuntime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
@@ -37,10 +40,92 @@ use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
async fn remote_control_preference(
|
||||
state_db: &StateRuntime,
|
||||
websocket_url: &str,
|
||||
) -> Result<Option<bool>> {
|
||||
Ok(state_db
|
||||
.get_remote_control_enrollment(websocket_url, "account_id", Some(DEFAULT_CLIENT_NAME))
|
||||
.await?
|
||||
.context("enrollment should exist")?
|
||||
.remote_control_enabled)
|
||||
}
|
||||
|
||||
async fn wait_for_response(mcp: &mut TestAppServer, request_id: i64) -> Result<JSONRPCResponse> {
|
||||
timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listen_off_honors_persisted_remote_control_enable() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let listener = configured_remote_control_listener(codex_home.path()).await?;
|
||||
let websocket_url = format!(
|
||||
"ws://{}/backend-api/wham/remote/control/server",
|
||||
listener.local_addr()?
|
||||
);
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?;
|
||||
state_db
|
||||
.upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord {
|
||||
websocket_url,
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: "server-id".to_string(),
|
||||
environment_id: "environment-id".to_string(),
|
||||
server_name: "server-name".to_string(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let _app_server = TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?;
|
||||
timeout(STARTUP_TIMEOUT, listener.accept()).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listen_off_exits_without_persisted_remote_control_enable() -> Result<()> {
|
||||
for persisted_preference in [None, Some(false)] {
|
||||
let codex_home = TempDir::new()?;
|
||||
let listener = configured_remote_control_listener(codex_home.path()).await?;
|
||||
if let Some(remote_control_enabled) = persisted_preference {
|
||||
let websocket_url = format!(
|
||||
"ws://{}/backend-api/wham/remote/control/server",
|
||||
listener.local_addr()?
|
||||
);
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
|
||||
.await?;
|
||||
state_db
|
||||
.upsert_remote_control_enrollment(&RemoteControlEnrollmentRecord {
|
||||
websocket_url,
|
||||
account_id: "account_id".to_string(),
|
||||
app_server_client_name: None,
|
||||
server_id: "server-id".to_string(),
|
||||
environment_id: "environment-id".to_string(),
|
||||
server_name: "server-name".to_string(),
|
||||
remote_control_enabled: Some(remote_control_enabled),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut app_server =
|
||||
TestAppServer::new_with_args(codex_home.path(), &["--listen", "off"]).await?;
|
||||
let status = timeout(STARTUP_TIMEOUT, app_server.wait_for_exit()).await??;
|
||||
assert!(!status.success());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_disable_returns_disabled_status() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let _listener = configured_remote_control_listener(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
@@ -83,11 +168,22 @@ async fn remote_control_status_read_returns_disabled_status() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn remote_control_enable_returns_connecting_status() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let _backend = BlockingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_remote_control_enable_request().await?;
|
||||
assert_eq!(
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
timeout(
|
||||
Duration::from_millis(100),
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.expect_err("enable response should wait for enrollment");
|
||||
backend.complete_enrollment()?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
@@ -97,11 +193,103 @@ async fn remote_control_enable_returns_connecting_status() -> Result<()> {
|
||||
|
||||
assert_eq!(received.status, RemoteControlConnectionStatus::Connecting);
|
||||
assert!(!received.server_name.is_empty());
|
||||
assert_eq!(received.environment_id, None);
|
||||
assert_eq!(received.environment_id.as_deref(), Some("environment-id"));
|
||||
assert!(!received.installation_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_waits_for_in_flight_durable_enable() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let websocket_url = backend.websocket_url().to_string();
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
mcp.send_remote_control_enable_request().await?;
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??;
|
||||
let disable_request_id = mcp.send_remote_control_disable_request().await?;
|
||||
timeout(
|
||||
Duration::from_millis(100),
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(disable_request_id)),
|
||||
)
|
||||
.await
|
||||
.expect_err("disable response should wait for the in-flight enable");
|
||||
|
||||
backend.complete_enrollment()?;
|
||||
let response = wait_for_response(&mut mcp, disable_request_id).await?;
|
||||
let received: RemoteControlDisableResponse = to_response(response)?;
|
||||
assert_eq!(received.status, RemoteControlConnectionStatus::Disabled);
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(false)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rpc_updates_durable_preference_but_ephemeral_does_not() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let websocket_url = backend.websocket_url().to_string();
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp.send_remote_control_enable_request().await?;
|
||||
assert_eq!(
|
||||
timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
backend.complete_enrollment()?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let request_id = mcp.send_remote_control_ephemeral_disable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let request_id = mcp.send_remote_control_disable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let request_id = mcp.send_remote_control_enable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let request_id = mcp.send_remote_control_disable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
let request_id = mcp.send_remote_control_ephemeral_enable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
assert_eq!(
|
||||
remote_control_preference(&state_db, &websocket_url).await?,
|
||||
Some(false)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -110,17 +298,17 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() ->
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
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??;
|
||||
|
||||
let enroll_request = timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??;
|
||||
assert_eq!(
|
||||
enroll_request,
|
||||
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
|
||||
);
|
||||
backend.complete_enrollment()?;
|
||||
let _: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request_id = mcp.send_remote_control_status_read_request().await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -132,7 +320,7 @@ async fn remote_control_status_read_returns_connecting_status_after_enable() ->
|
||||
|
||||
assert_eq!(received.status, RemoteControlConnectionStatus::Connecting);
|
||||
assert!(!received.server_name.is_empty());
|
||||
assert_eq!(received.environment_id, None);
|
||||
assert_eq!(received.environment_id.as_deref(), Some("environment-id"));
|
||||
assert!(!received.installation_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,11 +423,13 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> {
|
||||
async fn pairing_start_works_after_ephemeral_enable() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let request_id = mcp.send_remote_control_ephemeral_enable_request().await?;
|
||||
wait_for_response(&mut mcp, request_id).await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_remote_control_pairing_start_request(RemoteControlPairingStartParams {
|
||||
@@ -333,6 +523,8 @@ async fn remote_control_client_management_works_while_disabled() -> Result<()> {
|
||||
|
||||
struct BlockingRemoteControlBackend {
|
||||
enroll_request_rx: Option<oneshot::Receiver<Result<String>>>,
|
||||
enroll_response_tx: Option<oneshot::Sender<()>>,
|
||||
websocket_url: String,
|
||||
server_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -397,12 +589,37 @@ impl ClientManagementRemoteControlBackend {
|
||||
impl BlockingRemoteControlBackend {
|
||||
async fn start(codex_home: &std::path::Path) -> Result<Self> {
|
||||
let listener = configured_remote_control_listener(codex_home).await?;
|
||||
let websocket_url = format!(
|
||||
"ws://{}/backend-api/wham/remote/control/server",
|
||||
listener.local_addr()?
|
||||
);
|
||||
|
||||
let (enroll_request_tx, enroll_request_rx) = oneshot::channel();
|
||||
let (enroll_response_tx, enroll_response_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
match read_enroll_request(listener).await {
|
||||
Ok((request_line, _reader)) => {
|
||||
match read_enroll_request(&listener).await {
|
||||
Ok((request_line, reader)) => {
|
||||
let _ = enroll_request_tx.send(Ok(request_line));
|
||||
if enroll_response_rx.await.is_err() {
|
||||
return;
|
||||
}
|
||||
if respond_with_json(
|
||||
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",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(_websocket) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -413,6 +630,8 @@ impl BlockingRemoteControlBackend {
|
||||
|
||||
Ok(Self {
|
||||
enroll_request_rx: Some(enroll_request_rx),
|
||||
enroll_response_tx: Some(enroll_response_tx),
|
||||
websocket_url,
|
||||
server_task,
|
||||
})
|
||||
}
|
||||
@@ -424,6 +643,18 @@ impl BlockingRemoteControlBackend {
|
||||
.context("enroll request should only be awaited once")?;
|
||||
rx.await?
|
||||
}
|
||||
|
||||
fn complete_enrollment(&mut self) -> Result<()> {
|
||||
self.enroll_response_tx
|
||||
.take()
|
||||
.context("enrollment should only complete once")?
|
||||
.send(())
|
||||
.map_err(|()| anyhow::anyhow!("enrollment response receiver dropped"))
|
||||
}
|
||||
|
||||
fn websocket_url(&self) -> &str {
|
||||
&self.websocket_url
|
||||
}
|
||||
}
|
||||
|
||||
struct PairingRemoteControlBackend {
|
||||
@@ -558,8 +789,8 @@ async fn configured_remote_control_listener(codex_home: &std::path::Path) -> Res
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader<TcpStream>)> {
|
||||
let request = read_http_request(&listener).await?;
|
||||
async fn read_enroll_request(listener: &TcpListener) -> Result<(String, BufReader<TcpStream>)> {
|
||||
let request = read_http_request(listener).await?;
|
||||
Ok((request.request_line, request.reader))
|
||||
}
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ struct AppServerCommand {
|
||||
#[arg(long = "stdio", conflicts_with = "listen")]
|
||||
stdio: bool,
|
||||
|
||||
/// Enable remote control for this app-server process.
|
||||
/// Enable remote control for this app-server process without changing persistence.
|
||||
#[arg(long = "remote-control", hide = true)]
|
||||
remote_control: bool,
|
||||
|
||||
@@ -953,13 +953,17 @@ fn stage_str(stage: Stage) -> &'static str {
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
|
||||
cli_main(arg0_paths).await?;
|
||||
let remote_control_disabled = codex_app_server::take_remote_control_disabled_env();
|
||||
arg0_dispatch_or_else(move |arg0_paths: Arg0DispatchPaths| async move {
|
||||
cli_main(arg0_paths, remote_control_disabled).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
async fn cli_main(
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
remote_control_disabled: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let MultitoolCli {
|
||||
config_overrides: mut root_config_overrides,
|
||||
feature_toggles,
|
||||
@@ -1118,7 +1122,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
};
|
||||
let auth = auth.try_into_settings()?;
|
||||
let runtime_options = codex_app_server::AppServerRuntimeOptions {
|
||||
remote_control_enabled: remote_control,
|
||||
remote_control_startup_mode: match (remote_control, remote_control_disabled)
|
||||
{
|
||||
(true, _) => {
|
||||
codex_app_server::RemoteControlStartupMode::EnabledEphemeral
|
||||
}
|
||||
(false, true) => {
|
||||
codex_app_server::RemoteControlStartupMode::DisabledEphemeral
|
||||
}
|
||||
(false, false) => {
|
||||
codex_app_server::RemoteControlStartupMode::ResolvePersisted
|
||||
}
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
codex_app_server::run_main_with_transport_options(
|
||||
@@ -3411,6 +3426,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_remote_control_startup_flag_enables_remote_control() {
|
||||
let enabled = app_server_from_args(["codex", "app-server", "--remote-control"].as_ref());
|
||||
assert!(enabled.remote_control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_analytics_default_enabled_with_flag() {
|
||||
let app_server =
|
||||
|
||||
@@ -115,7 +115,7 @@ async fn run_foreground_remote_control(
|
||||
socket_path: socket_path.clone(),
|
||||
};
|
||||
let runtime_options = AppServerRuntimeOptions {
|
||||
remote_control_enabled: true,
|
||||
remote_control_startup_mode: codex_app_server::RemoteControlStartupMode::EnabledEphemeral,
|
||||
install_shutdown_signal_handler: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE remote_control_enrollments
|
||||
ADD COLUMN remote_control_enabled INTEGER;
|
||||
@@ -11,6 +11,7 @@ pub struct RemoteControlEnrollmentRecord {
|
||||
pub server_id: String,
|
||||
pub environment_id: String,
|
||||
pub server_name: String,
|
||||
pub remote_control_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn remote_control_app_server_client_name_key(app_server_client_name: Option<&str>) -> &str {
|
||||
@@ -34,7 +35,8 @@ impl StateRuntime {
|
||||
) -> anyhow::Result<Option<RemoteControlEnrollmentRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name
|
||||
SELECT websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name,
|
||||
remote_control_enabled
|
||||
FROM remote_control_enrollments
|
||||
WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ?
|
||||
"#,
|
||||
@@ -56,6 +58,7 @@ WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ?
|
||||
server_id: row.try_get("server_id")?,
|
||||
environment_id: row.try_get("environment_id")?,
|
||||
server_name: row.try_get("server_name")?,
|
||||
remote_control_enabled: row.try_get("remote_control_enabled")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
@@ -74,8 +77,9 @@ INSERT INTO remote_control_enrollments (
|
||||
server_id,
|
||||
environment_id,
|
||||
server_name,
|
||||
remote_control_enabled,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(websocket_url, account_id, app_server_client_name) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
environment_id = excluded.environment_id,
|
||||
@@ -91,12 +95,39 @@ ON CONFLICT(websocket_url, account_id, app_server_client_name) DO UPDATE SET
|
||||
.bind(&enrollment.server_id)
|
||||
.bind(&enrollment.environment_id)
|
||||
.bind(&enrollment.server_name)
|
||||
.bind(enrollment.remote_control_enabled)
|
||||
.bind(Utc::now().timestamp())
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_remote_control_enabled(
|
||||
&self,
|
||||
websocket_url: &str,
|
||||
account_id: &str,
|
||||
app_server_client_name: Option<&str>,
|
||||
remote_control_enabled: bool,
|
||||
) -> anyhow::Result<u64> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE remote_control_enrollments
|
||||
SET remote_control_enabled = ?, updated_at = ?
|
||||
WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ?
|
||||
"#,
|
||||
)
|
||||
.bind(remote_control_enabled)
|
||||
.bind(Utc::now().timestamp())
|
||||
.bind(websocket_url)
|
||||
.bind(account_id)
|
||||
.bind(remote_control_app_server_client_name_key(
|
||||
app_server_client_name,
|
||||
))
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn delete_remote_control_enrollment(
|
||||
&self,
|
||||
websocket_url: &str,
|
||||
@@ -125,7 +156,13 @@ mod tests {
|
||||
use super::RemoteControlEnrollmentRecord;
|
||||
use super::StateRuntime;
|
||||
use super::test_support::unique_temp_dir;
|
||||
use crate::migrations::STATE_MIGRATOR;
|
||||
use crate::state_db_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_enrollment_round_trips_by_target_and_account() {
|
||||
@@ -143,6 +180,7 @@ mod tests {
|
||||
server_id: "srv_e_first".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_name: "first-server".to_string(),
|
||||
remote_control_enabled: Some(false),
|
||||
})
|
||||
.await
|
||||
.expect("insert first enrollment");
|
||||
@@ -155,6 +193,7 @@ mod tests {
|
||||
server_id: "srv_e_second".to_string(),
|
||||
environment_id: "env_second".to_string(),
|
||||
server_name: "second-server".to_string(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
.await
|
||||
.expect("insert second enrollment");
|
||||
@@ -176,6 +215,7 @@ mod tests {
|
||||
server_id: "srv_e_first".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_name: "first-server".to_string(),
|
||||
remote_control_enabled: Some(false),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -220,6 +260,7 @@ mod tests {
|
||||
server_id: "srv_e_first".to_string(),
|
||||
environment_id: "env_first".to_string(),
|
||||
server_name: "first-server".to_string(),
|
||||
remote_control_enabled: Some(false),
|
||||
})
|
||||
.await
|
||||
.expect("insert first enrollment");
|
||||
@@ -232,6 +273,7 @@ mod tests {
|
||||
server_id: "srv_e_second".to_string(),
|
||||
environment_id: "env_second".to_string(),
|
||||
server_name: "second-server".to_string(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
.await
|
||||
.expect("insert second enrollment");
|
||||
@@ -275,9 +317,65 @@ mod tests {
|
||||
server_id: "srv_e_second".to_string(),
|
||||
environment_id: "env_second".to_string(),
|
||||
server_name: "second-server".to_string(),
|
||||
remote_control_enabled: Some(true),
|
||||
})
|
||||
);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_preserves_legacy_remote_control_preference_as_null() {
|
||||
let codex_home = unique_temp_dir();
|
||||
tokio::fs::create_dir_all(&codex_home)
|
||||
.await
|
||||
.expect("create codex home");
|
||||
let old_state_migrator = Migrator {
|
||||
migrations: Cow::Owned(STATE_MIGRATOR.migrations[..36].to_vec()),
|
||||
ignore_missing: false,
|
||||
locking: true,
|
||||
no_tx: false,
|
||||
table_name: STATE_MIGRATOR.table_name.clone(),
|
||||
create_schemas: STATE_MIGRATOR.create_schemas.clone(),
|
||||
};
|
||||
let pool = SqlitePool::connect_with(
|
||||
SqliteConnectOptions::new()
|
||||
.filename(state_db_path(codex_home.as_path()))
|
||||
.create_if_missing(true),
|
||||
)
|
||||
.await
|
||||
.expect("open old state db");
|
||||
old_state_migrator
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply old state schema");
|
||||
sqlx::query("INSERT INTO remote_control_enrollments (websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind("wss://example.com/backend-api/wham/remote/control/server")
|
||||
.bind("account-a")
|
||||
.bind("desktop-client")
|
||||
.bind("srv_e_first")
|
||||
.bind("env_first")
|
||||
.bind("first-server")
|
||||
.bind(1_i64)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert legacy enrollment");
|
||||
pool.close().await;
|
||||
|
||||
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("initialize runtime");
|
||||
let actual = runtime
|
||||
.get_remote_control_enrollment(
|
||||
"wss://example.com/backend-api/wham/remote/control/server",
|
||||
"account-a",
|
||||
Some("desktop-client"),
|
||||
)
|
||||
.await
|
||||
.expect("load migrated enrollment")
|
||||
.expect("legacy enrollment should remain");
|
||||
assert_eq!(actual.remote_control_enabled, None);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(codex_home).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user