app-server: implement device key v2 methods (#18430)

## 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`
This commit is contained in:
Ruslan Nigmatullin
2026-04-21 14:07:08 -07:00
committed by GitHub
parent e502f0b52d
commit 69c3d12274
17 changed files with 734 additions and 6 deletions

View File

@@ -9,6 +9,7 @@ use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::device_key_api::DeviceKeyApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
@@ -18,6 +19,7 @@ use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::transport::AppServerTransport;
use crate::transport::ConnectionOrigin;
use crate::transport::RemoteControlHandle;
use async_trait::async_trait;
use axum::http::HeaderValue;
@@ -35,6 +37,9 @@ use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::DeviceKeyCreateParams;
use codex_app_server_protocol::DeviceKeyPublicParams;
use codex_app_server_protocol::DeviceKeySignParams;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
@@ -164,6 +169,7 @@ pub(crate) struct MessageProcessor {
codex_message_processor: CodexMessageProcessor,
thread_manager: Arc<ThreadManager>,
config_api: ConfigApi,
device_key_api: DeviceKeyApi,
external_agent_config_api: ExternalAgentConfigApi,
fs_api: FsApi,
auth_manager: Arc<AuthManager>,
@@ -175,8 +181,9 @@ pub(crate) struct MessageProcessor {
remote_control_handle: Option<RemoteControlHandle>,
}
#[derive(Debug, Default)]
#[derive(Debug)]
pub(crate) struct ConnectionSessionState {
origin: ConnectionOrigin,
initialized: OnceLock<InitializedConnectionSessionState>,
}
@@ -188,11 +195,28 @@ struct InitializedConnectionSessionState {
client_version: String,
}
impl Default for ConnectionSessionState {
fn default() -> Self {
Self::new(ConnectionOrigin::WebSocket)
}
}
impl ConnectionSessionState {
pub(crate) fn new(origin: ConnectionOrigin) -> Self {
Self {
origin,
initialized: OnceLock::new(),
}
}
pub(crate) fn initialized(&self) -> bool {
self.initialized.get().is_some()
}
fn allows_device_key_requests(&self) -> bool {
self.origin.allows_device_key_requests()
}
pub(crate) fn experimental_api_enabled(&self) -> bool {
self.initialized
.get()
@@ -301,6 +325,7 @@ impl MessageProcessor {
thread_manager.clone(),
analytics_events_client.clone(),
);
let device_key_api = DeviceKeyApi::default();
let external_agent_config_api =
ExternalAgentConfigApi::new(config.codex_home.to_path_buf());
let fs_api = FsApi::default();
@@ -311,6 +336,7 @@ impl MessageProcessor {
codex_message_processor,
thread_manager: Arc::clone(&thread_manager),
config_api,
device_key_api,
external_agent_config_api,
fs_api,
auth_manager,
@@ -748,6 +774,7 @@ impl MessageProcessor {
let app_server_client_name = session.app_server_client_name().map(str::to_string);
let client_version = session.client_version().map(str::to_string);
let device_key_requests_allowed = session.allows_device_key_requests();
Arc::clone(self)
.handle_initialized_client_request(
connection_request_id,
@@ -755,6 +782,7 @@ impl MessageProcessor {
request_context,
app_server_client_name,
client_version,
device_key_requests_allowed,
)
.await;
}
@@ -766,6 +794,7 @@ impl MessageProcessor {
request_context: RequestContext,
app_server_client_name: Option<String>,
client_version: Option<String>,
device_key_requests_allowed: bool,
) {
let connection_id = connection_request_id.connection_id;
@@ -840,6 +869,39 @@ impl MessageProcessor {
})
.await;
}
ClientRequest::DeviceKeyCreate { request_id, params } => {
self.handle_device_key_create(
ConnectionRequestId {
connection_id,
request_id,
},
params,
device_key_requests_allowed,
)
.await;
}
ClientRequest::DeviceKeyPublic { request_id, params } => {
self.handle_device_key_public(
ConnectionRequestId {
connection_id,
request_id,
},
params,
device_key_requests_allowed,
)
.await;
}
ClientRequest::DeviceKeySign { request_id, params } => {
self.handle_device_key_sign(
ConnectionRequestId {
connection_id,
request_id,
},
params,
device_key_requests_allowed,
)
.await;
}
ClientRequest::FsReadFile { request_id, params } => {
self.handle_fs_read_file(
ConnectionRequestId {
@@ -1103,6 +1165,98 @@ impl MessageProcessor {
}
}
async fn handle_device_key_create(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyCreateParams,
device_key_requests_allowed: bool,
) {
if self
.reject_device_key_request_over_remote_transport(
request_id.clone(),
"device/key/create",
device_key_requests_allowed,
)
.await
{
return;
}
match self.device_key_api.create(params) {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_device_key_public(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyPublicParams,
device_key_requests_allowed: bool,
) {
if self
.reject_device_key_request_over_remote_transport(
request_id.clone(),
"device/key/public",
device_key_requests_allowed,
)
.await
{
return;
}
match self.device_key_api.public(params) {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_device_key_sign(
&self,
request_id: ConnectionRequestId,
params: DeviceKeySignParams,
device_key_requests_allowed: bool,
) {
if self
.reject_device_key_request_over_remote_transport(
request_id.clone(),
"device/key/sign",
device_key_requests_allowed,
)
.await
{
return;
}
match self.device_key_api.sign(params) {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn reject_device_key_request_over_remote_transport(
&self,
request_id: ConnectionRequestId,
method: &str,
device_key_requests_allowed: bool,
) -> bool {
if device_key_requests_allowed {
return false;
}
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{method} is not available over remote transports"),
data: None,
},
)
.await;
true
}
async fn handle_external_agent_config_detect(
&self,
request_id: ConnectionRequestId,