mirror of
https://github.com/openai/codex.git
synced 2026-04-30 17:36:40 +00:00
## Why The device-key protocol needs an app-server implementation that keeps local key operations behind the same request-processing boundary as other v2 APIs. app-server owns request dispatch, transport policy, documentation, and JSON-RPC error shaping. `codex-device-key` owns key binding, validation, platform provider selection, and signing mechanics. Keeping the adapter thin makes the boundary easier to review and avoids moving local key-management details into thread orchestration code. ## What changed - Added `DeviceKeyApi` as the app-server adapter around `DeviceKeyStore`. - Converted protocol protection policies, payload variants, algorithms, and protection classes to and from the device-key crate types. - Encoded SPKI public keys and DER signatures as base64 protocol fields. - Routed `device/key/create`, `device/key/public`, and `device/key/sign` through `MessageProcessor`. - Rejected remote transports before provider access while allowing local `stdio` and in-process callers to reach the device-key API. - Added stdio, in-process, and websocket tests for device-key validation and transport policy. - Documented the device-key methods in the app-server v2 method list. ## Test coverage - `device_key_create_rejects_empty_account_user_id` - `in_process_allows_device_key_requests_to_reach_device_key_api` - `device_key_methods_are_rejected_over_websocket` ## Stack This is PR 3 of 4 in the device-key app-server stack. It is stacked on #18429. ## Validation - `cargo test -p codex-app-server device_key` - `just fix -p codex-app-server`
120 lines
4.2 KiB
Rust
120 lines
4.2 KiB
Rust
use super::connection_handling_websocket::connect_websocket;
|
|
use super::connection_handling_websocket::create_config_toml;
|
|
use super::connection_handling_websocket::read_error_for_id;
|
|
use super::connection_handling_websocket::read_response_for_id;
|
|
use super::connection_handling_websocket::send_initialize_request;
|
|
use super::connection_handling_websocket::send_request;
|
|
use super::connection_handling_websocket::spawn_websocket_server;
|
|
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_mock_responses_server_sequence_unchecked;
|
|
use codex_app_server_protocol::RequestId;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
#[cfg(any(target_os = "macos", windows))]
|
|
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
|
#[cfg(not(any(target_os = "macos", windows)))]
|
|
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
Ok(mcp)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn device_key_create_rejects_empty_account_user_id() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = initialized_mcp(&codex_home).await?;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"device/key/create",
|
|
Some(json!({
|
|
"accountUserId": "",
|
|
"clientId": "cli_123",
|
|
})),
|
|
)
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
"invalid device key payload: accountUserId must not be empty"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn device_key_methods_are_rejected_over_websocket() -> Result<()> {
|
|
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
|
let codex_home = TempDir::new()?;
|
|
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
|
|
|
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
|
|
let mut ws = connect_websocket(bind_addr).await?;
|
|
send_initialize_request(&mut ws, /*id*/ 1, "device_key_ws_test").await?;
|
|
let initialize_response = read_response_for_id(&mut ws, /*id*/ 1).await?;
|
|
assert_eq!(initialize_response.id, RequestId::Integer(1));
|
|
|
|
let cases = [
|
|
(
|
|
"device/key/create",
|
|
json!({
|
|
"accountUserId": "acct_123",
|
|
"clientId": "cli_123",
|
|
}),
|
|
),
|
|
(
|
|
"device/key/public",
|
|
json!({
|
|
"keyId": "device-key-123",
|
|
}),
|
|
),
|
|
(
|
|
"device/key/sign",
|
|
json!({
|
|
"keyId": "device-key-123",
|
|
"payload": {
|
|
"type": "remoteControlClientConnection",
|
|
"nonce": "nonce-123",
|
|
"audience": "remote_control_client_websocket",
|
|
"sessionId": "wssess_123",
|
|
"targetOrigin": "https://chatgpt.com",
|
|
"targetPath": "/api/codex/remote/control/client",
|
|
"accountUserId": "acct_123",
|
|
"clientId": "cli_123",
|
|
"tokenExpiresAt": 4_102_444_800i64,
|
|
"tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
|
|
"scopes": ["remote_control_controller_websocket"],
|
|
},
|
|
}),
|
|
),
|
|
];
|
|
|
|
for (index, (method, params)) in cases.into_iter().enumerate() {
|
|
let id = 2 + index as i64;
|
|
send_request(&mut ws, method, id, Some(params)).await?;
|
|
let error = read_error_for_id(&mut ws, id).await?;
|
|
|
|
assert_eq!(error.error.code, -32600);
|
|
assert_eq!(
|
|
error.error.message,
|
|
format!("{method} is not available over remote transports")
|
|
);
|
|
}
|
|
|
|
process.kill().await?;
|
|
Ok(())
|
|
}
|