Refresh codex-rs mirror to upstream/main

This commit is contained in:
Auto Review
2026-05-29 10:50:34 +10:00
parent 883157357c
commit 23bed2ce68
686 changed files with 32321 additions and 9220 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
+677 -301
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -47,7 +47,9 @@ members = [
"ext/extension-api",
"ext/goal",
"ext/guardian",
"ext/image-generation",
"ext/memories",
"ext/web-search",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -58,7 +60,6 @@ members = [
"login",
"codex-mcp",
"mcp-server",
"memories/mcp",
"memories/read",
"memories/write",
"model-provider-info",
@@ -165,6 +166,7 @@ codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-goal-extension = { path = "ext/goal" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -181,6 +183,7 @@ codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-message-history = { path = "message-history" }
codex-memories-extension = { path = "ext/memories" }
codex-web-search-extension = { path = "ext/web-search" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
@@ -341,7 +344,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { version = "0.15.0", default-features = false }
rmcp = { version = "1.7.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustls = { version = "0.23", default-features = false, features = [
"ring",
@@ -365,13 +368,14 @@ sha2 = "0.10"
shlex = "1.3.0"
similar = "2.7.0"
socket2 = "0.6.1"
sqlx = { version = "0.8.6", default-features = false, features = [
sqlx = { version = "0.9.0", default-features = false, features = [
"chrono",
"json",
"macros",
"migrate",
"runtime-tokio-rustls",
"sqlite",
"runtime-tokio",
"tls-rustls",
"sqlite-bundled",
"time",
"uuid",
] }
@@ -263,6 +263,7 @@ fn sample_thread_resume_response_with_source(
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
initial_turns_page: None,
})
}
@@ -271,6 +272,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest
request_id: RequestId::Integer(request_id),
params: TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: None,
input: vec![
UserInput::Text {
text: "hello".to_string(),
@@ -390,6 +392,7 @@ fn sample_turn_steer_request(
params: TurnSteerParams {
thread_id: thread_id.to_string(),
expected_turn_id: expected_turn_id.to_string(),
client_user_message_id: None,
input: vec![
UserInput::Text {
text: "more".to_string(),
@@ -401,6 +404,7 @@ fn sample_turn_steer_request(
},
],
responsesapi_client_metadata: None,
additional_context: None,
},
}
}
@@ -1212,6 +1216,7 @@ fn compaction_event_serializes_expected_shape() {
completed_at: 106,
duration_ms: Some(6543),
},
"session-thread-1".to_string(),
sample_app_server_client_metadata(),
sample_runtime_metadata(),
Some(ThreadSource::User),
@@ -1228,6 +1233,7 @@ fn compaction_event_serializes_expected_shape() {
"event_type": "codex_compaction_event",
"event_params": {
"thread_id": "thread-1",
"session_id": "session-thread-1",
"turn_id": "turn-1",
"app_server_client": {
"product_client_id": DEFAULT_ORIGINATOR,
@@ -1306,6 +1312,7 @@ fn thread_initialized_event_serializes_expected_shape() {
event_type: "codex_thread_initialized",
event_params: ThreadInitializedEventParams {
thread_id: "thread-0".to_string(),
session_id: "session-thread-0".to_string(),
app_server_client: CodexAppServerClientMetadata {
product_client_id: DEFAULT_ORIGINATOR.to_string(),
client_name: Some("codex-tui".to_string()),
@@ -1337,6 +1344,7 @@ fn thread_initialized_event_serializes_expected_shape() {
"event_type": "codex_thread_initialized",
"event_params": {
"thread_id": "thread-0",
"session_id": "session-thread-0",
"app_server_client": {
"product_client_id": DEFAULT_ORIGINATOR,
"client_name": "codex-tui",
@@ -1604,6 +1612,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_thread_initialized");
assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1");
assert_eq!(
payload[0]["event_params"]["app_server_client"]["product_client_id"],
DEFAULT_ORIGINATOR
@@ -1780,6 +1789,7 @@ async fn compaction_event_ingests_custom_fact() {
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_compaction_event");
assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact");
assert_eq!(
@@ -1904,6 +1914,10 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload.as_array().expect("events array").len(), 1);
assert_eq!(payload[0]["event_type"], "codex_guardian_review");
assert_eq!(
payload[0]["event_params"]["session_id"],
"session-thread-guardian"
);
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian");
assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian");
@@ -2396,6 +2410,7 @@ async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() {
fn subagent_thread_started_review_serializes_expected_shape() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
product_client_id: "codex-tui".to_string(),
@@ -2439,6 +2454,7 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
.expect("valid thread id");
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-spawn".to_string(),
parent_thread_id: None,
product_client_id: "codex-tui".to_string(),
@@ -2458,18 +2474,21 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
));
let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event");
assert_eq!(payload["event_params"]["thread_id"], "thread-spawn");
assert_eq!(payload["event_params"]["thread_source"], "subagent");
assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn");
assert_eq!(
payload["event_params"]["parent_thread_id"],
"11111111-1111-1111-1111-111111111111"
);
assert_eq!(payload["event_params"]["session_id"], "session-root");
}
#[test]
fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-memory".to_string(),
parent_thread_id: None,
product_client_id: "codex-tui".to_string(),
@@ -2495,6 +2514,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
fn subagent_thread_started_other_serializes_expected_shape() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: None,
product_client_id: "codex-tui".to_string(),
@@ -2516,6 +2536,7 @@ fn subagent_thread_started_other_serializes_expected_shape() {
fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: Some("parent-thread-guardian".to_string()),
product_client_id: "codex-tui".to_string(),
@@ -2545,6 +2566,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
product_client_id: "codex-tui".to_string(),
@@ -2618,6 +2640,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
product_client_id: "parent-client".to_string(),
@@ -2665,6 +2688,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(payload[0]["event_params"]["session_id"], "session-root");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-review");
assert_eq!(
payload[0]["event_params"]["app_server_client"]["product_client_id"],
"parent-client"
@@ -2685,6 +2710,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-subagent".to_string(),
parent_thread_id: Some("thread-1".to_string()),
product_client_id: "codex-tui".to_string(),
@@ -3190,6 +3216,7 @@ fn turn_event_serializes_expected_shape() {
event_type: "codex_turn_event",
event_params: crate::events::CodexTurnEventParams {
thread_id: "thread-2".to_string(),
session_id: "session-thread-2".to_string(),
turn_id: "turn-2".to_string(),
app_server_client: sample_app_server_client_metadata(),
runtime: sample_runtime_metadata(),
@@ -3240,6 +3267,7 @@ fn turn_event_serializes_expected_shape() {
"event_type": "codex_turn_event",
"event_params": {
"thread_id": "thread-2",
"session_id": "session-thread-2",
"turn_id": "turn-2",
"submission_type": null,
"app_server_client": {
@@ -3341,6 +3369,10 @@ async fn accepted_turn_steer_emits_expected_event() {
let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event");
assert_eq!(payload["event_type"], json!("codex_turn_steer_event"));
assert_eq!(payload["event_params"]["thread_id"], json!("thread-2"));
assert_eq!(
payload["event_params"]["session_id"],
json!("session-thread-2")
);
assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2"));
assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2"));
assert_eq!(payload["event_params"]["num_input_images"], json!(1));
@@ -3558,6 +3590,10 @@ async fn turn_lifecycle_emits_turn_event() {
let payload = serde_json::to_value(&out[0]).expect("serialize turn event");
assert_eq!(payload["event_type"], json!("codex_turn_event"));
assert_eq!(payload["event_params"]["thread_id"], json!("thread-2"));
assert_eq!(
payload["event_params"]["session_id"],
json!("session-thread-2")
);
assert_eq!(payload["event_params"]["turn_id"], json!("turn-2"));
assert_eq!(
payload["event_params"]["app_server_client"],
+4
View File
@@ -89,6 +89,7 @@ fn sample_turn_start_request() -> ClientRequest {
request_id: RequestId::Integer(1),
params: TurnStartParams {
thread_id: "thread-1".to_string(),
client_user_message_id: None,
input: Vec::new(),
..Default::default()
},
@@ -101,8 +102,10 @@ fn sample_turn_steer_request() -> ClientRequest {
params: TurnSteerParams {
thread_id: "thread-1".to_string(),
expected_turn_id: "turn-1".to_string(),
client_user_message_id: None,
input: Vec::new(),
responsesapi_client_metadata: None,
additional_context: None,
},
}
}
@@ -171,6 +174,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload {
sandbox: AppServerSandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
initial_turns_page: None,
})
}
+8
View File
@@ -147,6 +147,7 @@ pub(crate) struct CodexRuntimeMetadata {
#[derive(Serialize)]
pub(crate) struct ThreadInitializedEventParams {
pub(crate) thread_id: String,
pub(crate) session_id: String,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) model: String,
@@ -420,6 +421,7 @@ impl GuardianReviewAnalyticsResult {
#[derive(Serialize)]
pub(crate) struct GuardianReviewEventPayload {
pub(crate) session_id: String,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
#[serde(flatten)]
@@ -738,6 +740,7 @@ pub(crate) struct CodexHookRunEventRequest {
#[derive(Serialize)]
pub(crate) struct CodexCompactionEventParams {
pub(crate) thread_id: String,
pub(crate) session_id: String,
pub(crate) turn_id: String,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
@@ -767,6 +770,7 @@ pub(crate) struct CodexCompactionEventRequest {
#[derive(Serialize)]
pub(crate) struct CodexTurnEventParams {
pub(crate) thread_id: String,
pub(crate) session_id: String,
pub(crate) turn_id: String,
// TODO(rhan-oai): Populate once queued/default submission type is plumbed from
// the turn/start callsites instead of always being reported as None.
@@ -821,6 +825,7 @@ pub(crate) struct CodexTurnEventRequest {
#[derive(Serialize)]
pub(crate) struct CodexTurnSteerEventParams {
pub(crate) thread_id: String,
pub(crate) session_id: String,
pub(crate) expected_turn_id: Option<String>,
pub(crate) accepted_turn_id: Option<String>,
pub(crate) app_server_client: CodexAppServerClientMetadata,
@@ -926,6 +931,7 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu
pub(crate) fn codex_compaction_event_params(
input: CodexCompactionEvent,
session_id: String,
app_server_client: CodexAppServerClientMetadata,
runtime: CodexRuntimeMetadata,
thread_source: Option<ThreadSource>,
@@ -934,6 +940,7 @@ pub(crate) fn codex_compaction_event_params(
) -> CodexCompactionEventParams {
CodexCompactionEventParams {
thread_id: input.thread_id,
session_id,
turn_id: input.turn_id,
app_server_client,
runtime,
@@ -1026,6 +1033,7 @@ pub(crate) fn subagent_thread_started_event_request(
) -> ThreadInitializedEvent {
let event_params = ThreadInitializedEventParams {
thread_id: input.thread_id,
session_id: input.session_id,
app_server_client: CodexAppServerClientMetadata {
product_client_id: input.product_client_id,
client_name: Some(input.client_name),
+1
View File
@@ -199,6 +199,7 @@ pub struct AppInvocation {
#[derive(Clone)]
pub struct SubAgentThreadStartedInput {
pub session_id: String,
pub thread_id: String,
pub parent_thread_id: Option<String>,
pub product_client_id: String,
+13 -2
View File
@@ -255,6 +255,7 @@ struct ItemReviewSummary {
#[derive(Clone)]
struct ThreadMetadataState {
session_id: String,
thread_source: Option<ThreadSource>,
initialization_mode: ThreadInitializationMode,
subagent_source: Option<String>,
@@ -263,6 +264,7 @@ struct ThreadMetadataState {
impl ThreadMetadataState {
fn from_thread_metadata(
session_id: String,
session_source: &SessionSource,
thread_source: Option<ThreadSource>,
initialization_mode: ThreadInitializationMode,
@@ -281,6 +283,7 @@ impl ThreadMetadataState {
| SessionSource::Unknown => (None, None),
};
Self {
session_id,
thread_source,
initialization_mode,
subagent_source,
@@ -525,6 +528,7 @@ impl AnalyticsReducer {
thread_state
.metadata
.get_or_insert_with(|| ThreadMetadataState {
session_id: input.session_id.clone(),
thread_source: Some(ThreadSource::Subagent),
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
@@ -543,8 +547,8 @@ impl AnalyticsReducer {
input: GuardianReviewEventParams,
out: &mut Vec<TrackEventRequest>,
) {
let Some(connection_state) =
self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input))
let Some((connection_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::guardian(&input))
else {
return;
};
@@ -552,6 +556,7 @@ impl AnalyticsReducer {
GuardianReviewEventRequest {
event_type: "codex_guardian_review",
event_params: GuardianReviewEventPayload {
session_id: thread_metadata.session_id.clone(),
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
guardian_review: input,
@@ -1231,11 +1236,13 @@ impl AnalyticsReducer {
out: &mut Vec<TrackEventRequest>,
) {
let session_source: SessionSource = thread.source.into();
let session_id = thread.session_id;
let thread_id = thread.id;
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
let thread_metadata = ThreadMetadataState::from_thread_metadata(
session_id.clone(),
&session_source,
thread.thread_source.map(Into::into),
initialization_mode,
@@ -1252,6 +1259,7 @@ impl AnalyticsReducer {
event_type: "codex_thread_initialized",
event_params: ThreadInitializedEventParams {
thread_id,
session_id,
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
model,
@@ -1277,6 +1285,7 @@ impl AnalyticsReducer {
event_type: "codex_compaction_event",
event_params: codex_compaction_event_params(
input,
thread_metadata.session_id.clone(),
connection_state.app_server_client.clone(),
connection_state.runtime.clone(),
thread_metadata.thread_source,
@@ -1379,6 +1388,7 @@ impl AnalyticsReducer {
event_type: "codex_turn_steer_event",
event_params: CodexTurnSteerEventParams {
thread_id: pending_request.thread_id,
session_id: thread_metadata.session_id.clone(),
expected_turn_id: Some(pending_request.expected_turn_id),
accepted_turn_id,
app_server_client: connection_state.app_server_client.clone(),
@@ -2447,6 +2457,7 @@ fn codex_turn_event_params(
let token_usage = turn_state.token_usage.clone();
CodexTurnEventParams {
thread_id,
session_id: thread_metadata.session_id.clone(),
turn_id,
app_server_client,
runtime,
+4 -1
View File
@@ -1122,7 +1122,9 @@ mod tests {
websocket,
JSONRPCMessage::Response(JSONRPCResponse {
id: request.id,
result: serde_json::json!({}),
result: serde_json::json!({
"userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust",
}),
}),
)
.await;
@@ -1457,6 +1459,7 @@ mod tests {
.await
.expect("remote client should connect");
assert_eq!(client.server_version(), Some("9.8.7-test"));
let response: GetAccountResponse = client
.request_typed(ClientRequest::GetAccount {
request_id: RequestId::Integer(1),
+20 -3
View File
@@ -150,6 +150,7 @@ pub struct RemoteAppServerClient {
command_tx: mpsc::Sender<RemoteClientCommand>,
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
pending_events: VecDeque<AppServerEvent>,
server_version: Option<String>,
worker_handle: tokio::task::JoinHandle<()>,
}
@@ -180,6 +181,10 @@ impl RemoteAppServerClient {
}
}
pub fn server_version(&self) -> Option<&str> {
self.server_version.as_deref()
}
async fn connect_with_stream<S>(
channel_capacity: usize,
endpoint: String,
@@ -190,7 +195,7 @@ impl RemoteAppServerClient {
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut stream = stream;
let pending_events = initialize_remote_connection(
let (pending_events, server_version) = initialize_remote_connection(
&mut stream,
&endpoint,
initialize_params,
@@ -466,6 +471,7 @@ impl RemoteAppServerClient {
command_tx,
event_rx,
pending_events: pending_events.into(),
server_version,
worker_handle,
})
}
@@ -606,6 +612,7 @@ impl RemoteAppServerClient {
command_tx,
event_rx,
pending_events: _pending_events,
server_version: _server_version,
worker_handle,
} = self;
let mut worker_handle = worker_handle;
@@ -793,12 +800,13 @@ async fn initialize_remote_connection<S>(
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<Vec<AppServerEvent>>
) -> IoResult<(Vec<AppServerEvent>, Option<String>)>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let initialize_request_id = RequestId::String("initialize".to_string());
let mut pending_events = Vec::new();
let mut server_version = None;
write_jsonrpc_message(
stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(
@@ -822,6 +830,14 @@ where
})?;
match message {
JSONRPCMessage::Response(response) if response.id == initialize_request_id => {
server_version = response
.result
.get("userAgent")
.and_then(serde_json::Value::as_str)
.and_then(|user_agent| {
let (_, rest) = user_agent.split_once('/')?;
rest.split_whitespace().next().map(str::to_string)
});
break Ok(());
}
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
@@ -913,7 +929,7 @@ where
)
.await?;
Ok(pending_events)
Ok((pending_events, server_version))
}
fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option<AppServerEvent> {
@@ -1007,6 +1023,7 @@ mod tests {
command_tx,
event_rx,
pending_events: VecDeque::new(),
server_version: None,
worker_handle,
};
+6
View File
@@ -74,6 +74,12 @@ pub struct BootstrapOptions {
pub remote_control_enabled: bool,
}
/// Passively probes an existing app-server socket and returns its reported
/// app-server version.
pub async fn probe_app_server_version(socket_path: &Path) -> Result<String> {
Ok(client::probe(socket_path).await?.app_server_version)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BootstrapStatus {
@@ -12,6 +12,28 @@
],
"type": "string"
},
"AdditionalContextEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/AdditionalContextKind"
},
"value": {
"type": "string"
}
},
"required": [
"kind",
"value"
],
"type": "object"
},
"AdditionalContextKind": {
"enum": [
"untrusted",
"application"
],
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -1063,6 +1085,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -1143,6 +1167,12 @@
"integer",
"null"
]
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"type": "object"
@@ -3513,6 +3543,42 @@
}
]
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
"properties": {
@@ -3919,6 +3985,12 @@
],
"description": "Override where approval requests are routed for review on this turn and subsequent turns."
},
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"cwd": {
"description": "Override the working directory for this turn and subsequent turns.",
"type": [
@@ -4005,6 +4077,12 @@
},
"TurnSteerParams": {
"properties": {
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
@@ -2031,6 +2031,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -3559,6 +3561,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -5768,6 +5768,28 @@
],
"type": "string"
},
"AdditionalContextEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/v2/AdditionalContextKind"
},
"value": {
"type": "string"
}
},
"required": [
"kind",
"value"
],
"type": "object"
},
"AdditionalContextKind": {
"enum": [
"untrusted",
"application"
],
"type": "string"
},
"AdditionalFileSystemPermissions": {
"properties": {
"entries": {
@@ -10138,6 +10160,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -10343,6 +10367,12 @@
"integer",
"null"
]
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"title": "ListMcpServerStatusParams",
@@ -10934,6 +10964,47 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
@@ -11044,6 +11115,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/v2/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/v2/Tool"
@@ -11764,7 +11845,7 @@
"NetworkUnixSocketPermission": {
"enum": [
"allow",
"none"
"deny"
],
"type": "string"
},
@@ -15915,6 +15996,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/v2/UserInput"
@@ -17120,6 +17207,42 @@
"title": "ThreadRealtimeTranscriptDoneNotification",
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/v2/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/v2/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -18296,6 +18419,12 @@
],
"description": "Override where approval requests are routed for review on this turn and subsequent turns."
},
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"cwd": {
"description": "Override the working directory for this turn and subsequent turns.",
"type": [
@@ -18423,6 +18552,12 @@
"TurnSteerParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
@@ -18458,6 +18593,32 @@
"title": "TurnSteerResponse",
"type": "object"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/v2/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -164,6 +164,28 @@
],
"type": "string"
},
"AdditionalContextEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/AdditionalContextKind"
},
"value": {
"type": "string"
}
},
"required": [
"kind",
"value"
],
"type": "object"
},
"AdditionalContextKind": {
"enum": [
"untrusted",
"application"
],
"type": "string"
},
"AdditionalFileSystemPermissions": {
"properties": {
"entries": {
@@ -6618,6 +6640,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -6872,6 +6896,12 @@
"integer",
"null"
]
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"title": "ListMcpServerStatusParams",
@@ -7463,6 +7493,47 @@
"title": "McpResourceReadResponse",
"type": "object"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerMigration": {
"properties": {
"name": {
@@ -7573,6 +7644,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/Tool"
@@ -8293,7 +8374,7 @@
"NetworkUnixSocketPermission": {
"enum": [
"allow",
"none"
"deny"
],
"type": "string"
},
@@ -13739,6 +13820,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -14944,6 +15031,42 @@
"title": "ThreadRealtimeTranscriptDoneNotification",
"type": "object"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"ThreadResumeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -16120,6 +16243,12 @@
],
"description": "Override where approval requests are routed for review on this turn and subsequent turns."
},
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"cwd": {
"description": "Override the working directory for this turn and subsequent turns.",
"type": [
@@ -16247,6 +16376,12 @@
"TurnSteerParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
@@ -16282,6 +16417,32 @@
"title": "TurnSteerResponse",
"type": "object"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -460,7 +460,7 @@
"NetworkUnixSocketPermission": {
"enum": [
"allow",
"none"
"deny"
],
"type": "string"
},
@@ -287,6 +287,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -498,6 +500,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -287,6 +287,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -498,6 +500,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -36,6 +36,12 @@
"integer",
"null"
]
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"title": "ListMcpServerStatusParams",
@@ -10,6 +10,47 @@
],
"type": "string"
},
"McpServerInfo": {
"description": "Presentation metadata advertised by an initialized MCP server.",
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"icons": {
"items": true,
"type": [
"array",
"null"
]
},
"name": {
"type": "string"
},
"title": {
"type": [
"string",
"null"
]
},
"version": {
"type": "string"
},
"websiteUrl": {
"type": [
"string",
"null"
]
}
},
"required": [
"name",
"version"
],
"type": "object"
},
"McpServerStatus": {
"properties": {
"authStatus": {
@@ -30,6 +71,16 @@
},
"type": "array"
},
"serverInfo": {
"anyOf": [
{
"$ref": "#/definitions/McpServerInfo"
},
{
"type": "null"
}
]
},
"tools": {
"additionalProperties": {
"$ref": "#/definitions/Tool"
@@ -165,6 +165,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -424,6 +424,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -642,6 +644,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -529,6 +529,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -1119,6 +1121,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -224,6 +224,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -981,6 +983,74 @@
"danger-full-access"
],
"type": "string"
},
"SortDirection": {
"enum": [
"asc",
"desc"
],
"type": "string"
},
"ThreadResumeInitialTurnsPageParams": {
"properties": {
"itemsView": {
"anyOf": [
{
"$ref": "#/definitions/TurnItemsView"
},
{
"type": "null"
}
],
"description": "How much item detail to include for each returned turn; defaults to summary."
},
"limit": {
"description": "Optional turn page size.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
},
"sortDirection": {
"anyOf": [
{
"$ref": "#/definitions/SortDirection"
},
{
"type": "null"
}
],
"description": "Optional turn pagination direction; defaults to descending."
}
},
"type": "object"
},
"TurnItemsView": {
"oneOf": [
{
"description": "`items` was not loaded for this turn. The field is intentionally empty.",
"enum": [
"notLoaded"
],
"type": "string"
},
{
"description": "`items` contains only a display summary for this turn.",
"enum": [
"summary"
],
"type": "string"
},
{
"description": "`items` contains every ThreadItem available from persisted app-server history for this turn.",
"enum": [
"full"
],
"type": "string"
}
]
}
},
"description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -529,6 +529,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -1119,6 +1121,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -1993,6 +2001,32 @@
],
"type": "string"
},
"TurnsPage": {
"properties": {
"backwardsCursor": {
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/Turn"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -529,6 +529,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -1119,6 +1121,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -450,6 +450,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -934,6 +936,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -424,6 +424,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -642,6 +644,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -5,6 +5,28 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AdditionalContextEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/AdditionalContextKind"
},
"value": {
"type": "string"
}
},
"required": [
"kind",
"value"
],
"type": "object"
},
"AdditionalContextKind": {
"enum": [
"untrusted",
"application"
],
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -101,6 +123,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -492,6 +516,12 @@
],
"description": "Override where approval requests are routed for review on this turn and subsequent turns."
},
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"cwd": {
"description": "Override the working directory for this turn and subsequent turns.",
"type": [
@@ -424,6 +424,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -642,6 +644,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -424,6 +424,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -642,6 +644,12 @@
"oneOf": [
{
"properties": {
"clientId": {
"type": [
"string",
"null"
]
},
"content": {
"items": {
"$ref": "#/definitions/UserInput"
@@ -1,6 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AdditionalContextEntry": {
"properties": {
"kind": {
"$ref": "#/definitions/AdditionalContextKind"
},
"value": {
"type": "string"
}
},
"required": [
"kind",
"value"
],
"type": "object"
},
"AdditionalContextKind": {
"enum": [
"untrusted",
"application"
],
"type": "string"
},
"ByteRange": {
"properties": {
"end": {
@@ -22,6 +44,8 @@
},
"ImageDetail": {
"enum": [
"auto",
"low",
"high",
"original"
],
@@ -194,6 +218,12 @@
}
},
"properties": {
"clientUserMessageId": {
"type": [
"string",
"null"
]
},
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageDetail = "high" | "original";
export type ImageDetail = "auto" | "low" | "high" | "original";
@@ -0,0 +1,9 @@
// 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.
import type { JsonValue } from "./serde_json/JsonValue";
/**
* Presentation metadata advertised by an initialized MCP server.
*/
export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array<JsonValue> | null, websiteUrl: string | null, };
+1
View File
@@ -42,6 +42,7 @@ export type { InternalSessionSource } from "./InternalSessionSource";
export type { LocalShellAction } from "./LocalShellAction";
export type { LocalShellExecAction } from "./LocalShellExecAction";
export type { LocalShellStatus } from "./LocalShellStatus";
export type { McpServerInfo } from "./McpServerInfo";
export type { MessagePhase } from "./MessagePhase";
export type { ModeKind } from "./ModeKind";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
@@ -0,0 +1,6 @@
// 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.
import type { AdditionalContextKind } from "./AdditionalContextKind";
export type AdditionalContextEntry = { value: string, kind: AdditionalContextKind, };
@@ -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 AdditionalContextKind = "untrusted" | "application";
@@ -16,4 +16,4 @@ limit?: number | null,
* Controls how much MCP inventory data to fetch for each server.
* Defaults to `Full` when omitted.
*/
detail?: McpServerStatusDetail | null, };
detail?: McpServerStatusDetail | null, threadId?: string | null, };
@@ -1,9 +1,10 @@
// 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.
import type { McpServerInfo } from "../McpServerInfo";
import type { Resource } from "../Resource";
import type { ResourceTemplate } from "../ResourceTemplate";
import type { Tool } from "../Tool";
import type { McpAuthStatus } from "./McpAuthStatus";
export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array<Resource>, resourceTemplates: Array<ResourceTemplate>, authStatus: McpAuthStatus, };
export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array<Resource>, resourceTemplates: Array<ResourceTemplate>, authStatus: McpAuthStatus, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkUnixSocketPermission = "allow" | "none";
export type NetworkUnixSocketPermission = "allow" | "deny";
@@ -23,7 +23,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus";
import type { UserInput } from "./UserInput";
import type { WebSearchAction } from "./WebSearchAction";
export type ThreadItem = { "type": "userMessage", id: string, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* The command to be executed.
*/
@@ -0,0 +1,19 @@
// 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.
import type { SortDirection } from "./SortDirection";
import type { TurnItemsView } from "./TurnItemsView";
export type ThreadResumeInitialTurnsPageParams = {
/**
* Optional turn page size.
*/
limit?: number | null,
/**
* Optional turn pagination direction; defaults to descending.
*/
sortDirection?: SortDirection | null,
/**
* How much item detail to include for each returned turn; defaults to summary.
*/
itemsView?: TurnItemsView | null, };
@@ -10,7 +10,7 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { UserInput } from "./UserInput";
export type TurnStartParams = {threadId: string, input: Array<UserInput>, /**
export type TurnStartParams = {threadId: string, clientUserMessageId?: string | null, input: Array<UserInput>, /**
* Override the working directory for this turn and subsequent turns.
*/
cwd?: string | null, /**
@@ -3,7 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { UserInput } from "./UserInput";
export type TurnSteerParams = {threadId: string, input: Array<UserInput>, /**
export type TurnSteerParams = {threadId: string, clientUserMessageId?: string | null, input: Array<UserInput>, /**
* Required active turn id precondition. The request fails when it does not
* match the currently active turn.
*/
@@ -0,0 +1,6 @@
// 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.
import type { Turn } from "./Turn";
export type TurnsPage = { data: Array<Turn>, nextCursor: string | null, backwardsCursor: string | null, };
@@ -7,6 +7,8 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { ActivePermissionProfile } from "./ActivePermissionProfile";
export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";
export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus";
export type { AdditionalContextEntry } from "./AdditionalContextEntry";
export type { AdditionalContextKind } from "./AdditionalContextKind";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
@@ -396,6 +398,7 @@ export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTranspor
export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification";
export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification";
export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification";
export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams";
export type { ThreadResumeParams } from "./ThreadResumeParams";
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
export type { ThreadRollbackParams } from "./ThreadRollbackParams";
@@ -448,6 +451,7 @@ export type { TurnStartedNotification } from "./TurnStartedNotification";
export type { TurnStatus } from "./TurnStatus";
export type { TurnSteerParams } from "./TurnSteerParams";
export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { TurnsPage } from "./TurnsPage";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WebSearchAction } from "./WebSearchAction";
+8 -16
View File
@@ -948,10 +948,8 @@ impl ScanState {
'(' => self.depth.paren += 1,
')' => self.depth.paren = (self.depth.paren - 1).max(0),
'<' => self.depth.angle += 1,
'>' => {
if self.depth.angle > 0 {
self.depth.angle -= 1;
}
'>' if self.depth.angle > 0 => {
self.depth.angle -= 1;
}
_ => {}
}
@@ -2212,20 +2210,14 @@ mod tests {
continue;
}
match ch {
'\\' => {
if in_single || in_double {
escape = true;
}
'\\' if (in_single || in_double) => {
escape = true;
}
'\'' => {
if !in_double {
in_single = !in_single;
}
'\'' if !in_double => {
in_single = !in_single;
}
'"' => {
if !in_single {
in_double = !in_double;
}
'"' if !in_single => {
in_double = !in_double;
}
'{' if !in_single && !in_double => level_brace += 1,
'}' if !in_single && !in_double => level_brace -= 1,
@@ -278,7 +278,11 @@ impl ThreadHistoryBuilder {
.unwrap_or_else(|| self.new_turn(/*id*/ None));
let id = self.next_item_id();
let content = self.build_user_inputs(payload);
turn.items.push(ThreadItem::UserMessage { id, content });
turn.items.push(ThreadItem::UserMessage {
id,
client_id: payload.client_id.clone(),
content,
});
self.current_turn = Some(turn);
}
@@ -1246,6 +1250,7 @@ mod tests {
fn builds_multiple_turns_with_reasoning_items() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "First turn".into(),
images: Some(vec!["https://example.com/one.png".into()]),
text_elements: Vec::new(),
@@ -1264,6 +1269,7 @@ mod tests {
text: "full reasoning".into(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Second turn".into(),
images: None,
text_elements: Vec::new(),
@@ -1292,6 +1298,7 @@ mod tests {
first.items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![
UserInput::Text {
text: "First turn".into(),
@@ -1330,6 +1337,7 @@ mod tests {
second.items[0],
ThreadItem::UserMessage {
id: "item-4".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Second turn".into(),
text_elements: Vec::new(),
@@ -1352,6 +1360,7 @@ mod tests {
let local_path = PathBuf::from("/tmp/local.png");
let events = vec![RolloutItem::EventMsg(EventMsg::UserMessage(
UserMessageEvent {
client_id: None,
message: "inspect these".into(),
images: Some(vec!["https://example.com/image.png".into()]),
image_details: vec![Some(ImageDetail::Original)],
@@ -1368,6 +1377,7 @@ mod tests {
turns[0].items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![
UserInput::Text {
text: "inspect these".into(),
@@ -1399,6 +1409,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "hello".into(),
images: None,
text_elements: Vec::new(),
@@ -1410,6 +1421,7 @@ mod tests {
turn_id: turn_id.to_string(),
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
id: "user-item-id".to_string(),
client_id: None,
content: Vec::new(),
}),
started_at_ms: 0,
@@ -1434,6 +1446,7 @@ mod tests {
turns[0].items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
@@ -1442,6 +1455,67 @@ mod tests {
);
}
#[test]
fn preserves_user_message_client_id_from_legacy_event() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemStarted(ItemStartedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
id: "user-item-id".to_string(),
client_id: Some("client-message-1".to_string()),
content: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
}),
started_at_ms: 0,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: Some("client-message-1".to_string()),
message: "hello".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
..Default::default()
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items,
vec![ThreadItem::UserMessage {
id: "item-1".into(),
client_id: Some("client-message-1".to_string()),
content: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
}]
);
}
#[test]
fn preserves_agent_message_phase_in_history() {
let events = vec![EventMsg::AgentMessage(AgentMessageEvent {
@@ -1478,6 +1552,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "generate an image".into(),
images: None,
text_elements: Vec::new(),
@@ -1515,6 +1590,7 @@ mod tests {
items: vec![
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "generate an image".into(),
text_elements: Vec::new(),
@@ -1536,6 +1612,7 @@ mod tests {
fn splits_reasoning_when_interleaved() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Turn start".into(),
images: None,
text_elements: Vec::new(),
@@ -1589,6 +1666,7 @@ mod tests {
fn marks_turn_as_interrupted_when_aborted() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Please do the thing".into(),
images: None,
text_elements: Vec::new(),
@@ -1607,6 +1685,7 @@ mod tests {
duration_ms: None,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Let's try again".into(),
images: None,
text_elements: Vec::new(),
@@ -1634,6 +1713,7 @@ mod tests {
first_turn.items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Please do the thing".into(),
text_elements: Vec::new(),
@@ -1657,6 +1737,7 @@ mod tests {
second_turn.items[0],
ThreadItem::UserMessage {
id: "item-3".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Let's try again".into(),
text_elements: Vec::new(),
@@ -1678,6 +1759,7 @@ mod tests {
fn drops_last_turns_on_thread_rollback() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "First".into(),
images: None,
text_elements: Vec::new(),
@@ -1690,6 +1772,7 @@ mod tests {
memory_citation: None,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Second".into(),
images: None,
text_elements: Vec::new(),
@@ -1703,6 +1786,7 @@ mod tests {
}),
EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Third".into(),
images: None,
text_elements: Vec::new(),
@@ -1732,6 +1816,7 @@ mod tests {
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "First".into(),
text_elements: Vec::new(),
@@ -1750,6 +1835,7 @@ mod tests {
vec![
ThreadItem::UserMessage {
id: "item-3".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Third".into(),
text_elements: Vec::new(),
@@ -1769,6 +1855,7 @@ mod tests {
fn thread_rollback_clears_all_turns_when_num_turns_exceeds_history() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "One".into(),
images: None,
text_elements: Vec::new(),
@@ -1781,6 +1868,7 @@ mod tests {
memory_citation: None,
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Two".into(),
images: None,
text_elements: Vec::new(),
@@ -1814,6 +1902,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Start".into(),
images: None,
text_elements: Vec::new(),
@@ -1821,6 +1910,7 @@ mod tests {
..Default::default()
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "Steer".into(),
images: None,
text_elements: Vec::new(),
@@ -1848,6 +1938,7 @@ mod tests {
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Start".into(),
text_elements: Vec::new(),
@@ -1855,6 +1946,7 @@ mod tests {
},
ThreadItem::UserMessage {
id: "item-2".into(),
client_id: None,
content: vec![UserInput::Text {
text: "Steer".into(),
text_elements: Vec::new(),
@@ -1875,6 +1967,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "run tools".into(),
images: None,
text_elements: Vec::new(),
@@ -2054,6 +2147,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "run dynamic tool".into(),
images: None,
text_elements: Vec::new(),
@@ -2121,6 +2215,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "run tools".into(),
images: None,
text_elements: Vec::new(),
@@ -2212,6 +2307,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "review this command".into(),
images: None,
text_elements: Vec::new(),
@@ -2297,6 +2393,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "run a subcommand".into(),
images: None,
text_elements: Vec::new(),
@@ -2362,6 +2459,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "first".into(),
images: None,
text_elements: Vec::new(),
@@ -2383,6 +2481,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "second".into(),
images: None,
text_elements: Vec::new(),
@@ -2458,6 +2557,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "first".into(),
images: None,
text_elements: Vec::new(),
@@ -2479,6 +2579,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "second".into(),
images: None,
text_elements: Vec::new(),
@@ -2528,6 +2629,7 @@ mod tests {
turns[1].items[0],
ThreadItem::UserMessage {
id: "item-2".into(),
client_id: None,
content: vec![UserInput::Text {
text: "second".into(),
text_elements: Vec::new(),
@@ -2549,6 +2651,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "apply patch".into(),
images: None,
text_elements: Vec::new(),
@@ -2584,6 +2687,7 @@ mod tests {
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
@@ -2615,6 +2719,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "apply patch".into(),
images: None,
text_elements: Vec::new(),
@@ -2652,6 +2757,7 @@ mod tests {
vec![
ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "apply patch".into(),
text_elements: Vec::new(),
@@ -2681,6 +2787,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "first".into(),
images: None,
text_elements: Vec::new(),
@@ -2702,6 +2809,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "second".into(),
images: None,
text_elements: Vec::new(),
@@ -2751,6 +2859,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "first".into(),
images: None,
text_elements: Vec::new(),
@@ -2772,6 +2881,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "second".into(),
images: None,
text_elements: Vec::new(),
@@ -2846,6 +2956,7 @@ mod tests {
fn reconstructs_collab_resume_end_item() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "resume agent".into(),
images: None,
text_elements: Vec::new(),
@@ -2904,6 +3015,7 @@ mod tests {
.expect("valid receiver thread id");
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "spawn agent".into(),
images: None,
text_elements: Vec::new(),
@@ -2966,6 +3078,7 @@ mod tests {
.expect("valid receiver thread id");
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "redirect".into(),
images: None,
text_elements: Vec::new(),
@@ -3030,6 +3143,7 @@ mod tests {
fn rollback_failed_error_does_not_mark_turn_failed() {
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "hello".into(),
images: None,
text_elements: Vec::new(),
@@ -3068,6 +3182,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "hello".into(),
images: None,
text_elements: Vec::new(),
@@ -3105,6 +3220,7 @@ mod tests {
items_view: TurnItemsView::Full,
items: vec![ThreadItem::UserMessage {
id: "item-1".into(),
client_id: None,
content: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
@@ -3125,6 +3241,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "hello".into(),
images: None,
text_elements: Vec::new(),
@@ -3184,6 +3301,7 @@ mod tests {
collaboration_mode_kind: Default::default(),
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "hello".into(),
images: None,
text_elements: Vec::new(),
@@ -490,7 +490,7 @@ pub enum NetworkDomainPermission {
#[ts(export_to = "v2/")]
pub enum NetworkUnixSocketPermission {
Allow,
None,
Deny,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -212,7 +212,11 @@ impl CommandAction {
pub enum ThreadItem {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
UserMessage { id: String, content: Vec<UserInput> },
UserMessage {
id: String,
client_id: Option<String>,
content: Vec<UserInput>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
HookPrompt {
@@ -776,6 +780,7 @@ impl From<CoreTurnItem> for ThreadItem {
match value {
CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage {
id: user.id,
client_id: user.client_id,
content: user.content.into_iter().map(UserInput::from).collect(),
},
CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt {
@@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core;
use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::items::McpToolCallError as CoreMcpToolCallError;
use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::mcp::Resource as McpResource;
pub use codex_protocol::mcp::ResourceContent as McpResourceContent;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
@@ -36,6 +37,8 @@ pub struct ListMcpServerStatusParams {
/// Defaults to `Full` when omitted.
#[ts(optional = nullable)]
pub detail: Option<McpServerStatusDetail>,
#[ts(optional = nullable)]
pub thread_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
@@ -51,6 +54,7 @@ pub enum McpServerStatusDetail {
#[ts(export_to = "v2/")]
pub struct McpServerStatus {
pub name: String,
pub server_info: Option<McpServerInfo>,
pub tools: std::collections::HashMap<String, McpTool>,
pub resources: Vec<McpResource>,
pub resource_templates: Vec<McpResourceTemplate>,
@@ -688,6 +692,7 @@ impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitatio
Self {
action: value.action.into(),
content: value.content,
meta: None,
}
}
}
@@ -11,6 +11,7 @@ use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
@@ -111,6 +112,85 @@ fn thread_turns_list_params_accepts_items_view() {
assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded));
}
#[test]
fn thread_resume_params_accept_turns_page_bootstrap() {
let params = serde_json::from_value::<ThreadResumeParams>(json!({
"threadId": "thr_123",
"initialTurnsPage": {
"limit": 25,
"sortDirection": "asc",
"itemsView": "full",
},
}))
.expect("thread resume params should deserialize");
assert_eq!(params.thread_id, "thr_123");
assert_eq!(
params.initial_turns_page,
Some(ThreadResumeInitialTurnsPageParams {
limit: Some(25),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::Full),
})
);
}
#[test]
fn thread_resume_response_round_trips_initial_turns_page() {
let response = ThreadResumeResponse {
thread: Thread {
id: "thr_123".to_string(),
session_id: "thr_123".to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
status: ThreadStatus::Idle,
path: None,
cwd: absolute_path("tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Exec,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: Vec::new(),
},
model: "gpt-5".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: absolute_path("tmp"),
runtime_workspace_roots: Vec::new(),
instruction_sources: Vec::new(),
approval_policy: AskForApproval::OnFailure,
approvals_reviewer: ApprovalsReviewer::User,
sandbox: SandboxPolicy::DangerFullAccess,
active_permission_profile: None,
reasoning_effort: None,
initial_turns_page: Some(TurnsPage {
data: Vec::new(),
next_cursor: Some("cursor_next".to_string()),
backwards_cursor: Some("cursor_back".to_string()),
}),
};
let value = serde_json::to_value(&response).expect("serialize thread resume response");
assert_eq!(
value.get("initialTurnsPage"),
Some(&json!({
"data": [],
"nextCursor": "cursor_next",
"backwardsCursor": "cursor_back",
}))
);
let decoded = serde_json::from_value::<ThreadResumeResponse>(value)
.expect("deserialize thread resume response");
assert_eq!(decoded, response);
}
#[test]
fn thread_turns_items_list_round_trips() {
let params = ThreadTurnsItemsListParams {
@@ -1676,6 +1756,7 @@ fn client_request_turn_start_granular_approval_policy_is_marked_experimental() {
request_id: crate::RequestId::Integer(4),
params: TurnStartParams {
thread_id: "thr_123".to_string(),
client_user_message_id: None,
input: Vec::new(),
approval_policy: Some(AskForApproval::Granular {
sandbox_approval: false,
@@ -1699,6 +1780,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() {
content: Some(json!({
"confirmed": true,
})),
meta: None,
};
let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone());
@@ -1923,6 +2005,80 @@ fn mcp_server_elicitation_response_serializes_nullable_content() {
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_as_null() {
let response = ListMcpServerStatusResponse {
data: vec![McpServerStatus {
name: "not-ready".to_string(),
server_info: None,
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: McpAuthStatus::Unsupported,
}],
next_cursor: None,
};
assert_eq!(
serde_json::to_value(response).expect("response should serialize"),
json!({
"data": [{
"name": "not-ready",
"serverInfo": null,
"tools": {},
"resources": [],
"resourceTemplates": [],
"authStatus": "unsupported",
}],
"nextCursor": null,
})
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_metadata_as_null() {
let response = ListMcpServerStatusResponse {
data: vec![McpServerStatus {
name: "initialized".to_string(),
server_info: Some(McpServerInfo {
name: "lookup-server".to_string(),
title: None,
version: "1.0.0".to_string(),
description: None,
icons: None,
website_url: None,
}),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: McpAuthStatus::Unsupported,
}],
next_cursor: None,
};
assert_eq!(
serde_json::to_value(response).expect("response should serialize"),
json!({
"data": [{
"name": "initialized",
"serverInfo": {
"name": "lookup-server",
"title": null,
"version": "1.0.0",
"description": null,
"icons": null,
"websiteUrl": null,
},
"tools": {},
"resources": [],
"resourceTemplates": [],
"authStatus": "unsupported",
}],
"nextCursor": null,
})
);
}
#[test]
fn sandbox_policy_round_trips_workspace_write_access() {
let v2_policy = SandboxPolicy::WorkspaceWrite {
@@ -2122,7 +2278,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() {
),
(
"/tmp/ignored.sock".to_string(),
NetworkUnixSocketPermission::None,
NetworkUnixSocketPermission::Deny,
),
])),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
@@ -2146,7 +2302,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() {
"allowedDomains": ["api.openai.com"],
"deniedDomains": ["blocked.example.com"],
"unixSockets": {
"/tmp/ignored.sock": "none",
"/tmp/ignored.sock": "deny",
"/tmp/proxy.sock": "allow"
},
"allowUnixSockets": ["/tmp/proxy.sock"],
@@ -2159,6 +2315,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() {
fn core_turn_item_into_thread_item_converts_supported_variants() {
let user_item = TurnItem::UserMessage(UserMessageItem {
id: "user-1".to_string(),
client_id: Some("client-message-1".to_string()),
content: vec![
CoreUserInput::Text {
text: "hello".to_string(),
@@ -2187,6 +2344,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
ThreadItem::from(user_item),
ThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: Some("client-message-1".to_string()),
content: vec![
UserInput::Text {
text: "hello".to_string(),
@@ -3405,6 +3563,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.active_permission_profile, None);
assert_eq!(resume.active_permission_profile, None);
assert_eq!(resume.initial_turns_page, None);
assert_eq!(fork.active_permission_profile, None);
}
@@ -3426,8 +3585,10 @@ fn turn_start_params_preserve_explicit_null_service_tier() {
let without_override = TurnStartParams {
thread_id: "thread_123".to_string(),
client_user_message_id: None,
input: vec![],
responsesapi_client_metadata: None,
additional_context: None,
environments: None,
cwd: None,
runtime_workspace_roots: None,
@@ -397,6 +397,11 @@ pub struct ThreadResumeParams {
#[experimental("thread/resume.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// When present, include a `thread/turns/list` page in the resume response
/// so clients can bootstrap recent turns without a second request.
#[experimental("thread/resume.initialTurnsPage")]
#[ts(optional = nullable)]
pub initial_turns_page: Option<ThreadResumeInitialTurnsPageParams>,
/// Deprecated and ignored by app-server. Kept only so older clients can
/// continue sending the field while rollout persistence always uses the
/// limited history policy.
@@ -435,6 +440,44 @@ pub struct ThreadResumeResponse {
#[serde(default)]
pub active_permission_profile: Option<ActivePermissionProfile>,
pub reasoning_effort: Option<ReasoningEffort>,
/// `thread/turns/list` page returned when requested by `initialTurnsPage`.
#[experimental("thread/resume.initialTurnsPage")]
#[serde(default)]
pub initial_turns_page: Option<TurnsPage>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadResumeInitialTurnsPageParams {
/// Optional turn page size.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional turn pagination direction; defaults to descending.
#[ts(optional = nullable)]
pub sort_direction: Option<SortDirection>,
/// How much item detail to include for each returned turn; defaults to summary.
#[ts(optional = nullable)]
pub items_view: Option<TurnItemsView>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnsPage {
pub data: Vec<Turn>,
pub next_cursor: Option<String>,
pub backwards_cursor: Option<String>,
}
impl From<ThreadTurnsListResponse> for TurnsPage {
fn from(response: ThreadTurnsListResponse) -> Self {
Self {
data: response.data,
next_cursor: response.next_cursor,
backwards_cursor: response.backwards_cursor,
}
}
}
#[derive(
@@ -41,6 +41,23 @@ pub struct TurnEnvironmentParams {
pub cwd: AbsolutePathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum AdditionalContextKind {
Untrusted,
Application,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AdditionalContextEntry {
pub value: String,
pub kind: AdditionalContextKind,
}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
@@ -48,11 +65,17 @@ pub struct TurnEnvironmentParams {
#[ts(export_to = "v2/")]
pub struct TurnStartParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub client_user_message_id: Option<String>,
pub input: Vec<UserInput>,
/// Optional turn-scoped Responses API client metadata.
#[experimental("turn/start.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
#[experimental("turn/start.additionalContext")]
#[ts(optional = nullable)]
pub additional_context: Option<HashMap<String, AdditionalContextEntry>>,
/// Optional turn-scoped environments.
///
/// Omitted uses the thread sticky environments. Empty disables
@@ -136,11 +159,17 @@ pub struct TurnStartResponse {
#[ts(export_to = "v2/")]
pub struct TurnSteerParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub client_user_message_id: Option<String>,
pub input: Vec<UserInput>,
/// Optional turn-scoped Responses API client metadata.
#[experimental("turn/steer.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
/// Optional client-provided context fragments keyed by an opaque source identifier.
#[experimental("turn/steer.additionalContext")]
#[ts(optional = nullable)]
pub additional_context: Option<HashMap<String, AdditionalContextEntry>>,
/// Required active turn id precondition. The request fails when it does not
/// match the currently active turn.
pub expected_turn_id: String,
@@ -196,7 +196,7 @@ fn canonicalize_json(value: &Value) -> Value {
}
Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
entries.sort_by_key(|(key, _)| *key);
let mut sorted = Map::with_capacity(map.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize_json(child));
@@ -734,6 +734,7 @@ async fn trigger_zsh_fork_multi_cmd_approval(
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: message,
text_elements: Vec::new(),
@@ -818,6 +819,7 @@ async fn resume_message_v2(
let turn_response = client.turn_start(TurnStartParams {
thread_id: resume_response.thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: user_message,
text_elements: Vec::new(),
@@ -959,6 +961,7 @@ async fn send_message_v2_with_policies(
println!("< thread/start response: {thread_response:?}");
let mut turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: user_message,
// Test client sends plain text without UI element ranges.
@@ -999,6 +1002,7 @@ async fn send_follow_up_v2(
let first_turn_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: first_message,
// Test client sends plain text without UI element ranges.
@@ -1012,6 +1016,7 @@ async fn send_follow_up_v2(
let follow_up_params = TurnStartParams {
thread_id: thread_response.thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: follow_up_message,
// Test client sends plain text without UI element ranges.
@@ -1255,6 +1260,7 @@ fn live_elicitation_timeout_pause(
let started_at = Instant::now();
let turn_response = client.turn_start(TurnStartParams {
thread_id: thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: prompt,
text_elements: Vec::new(),
@@ -15,13 +15,21 @@ use codex_app_server_protocol::JSONRPCMessage;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio::task::JoinSet;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
use tracing::info;
use tracing::warn;
const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30);
#[cfg(not(test))]
const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_millis(10);
#[derive(Debug)]
pub(crate) struct Stopped;
@@ -138,9 +146,6 @@ impl ClientTracker {
if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| {
client.last_activity_at = Instant::now();
if let Some(seq_id) = seq_id {
client.last_inbound_seq_id = Some(seq_id);
}
client.connection_id
}) {
self.send_transport_event(TransportEvent::IncomingMessage {
@@ -148,6 +153,7 @@ impl ClientTracker {
message,
})
.await?;
self.record_inbound_message_delivery(&client_key, seq_id);
return Ok(());
}
@@ -177,23 +183,37 @@ impl ClientTracker {
disconnect_token.clone(),
));
self.clients.insert(
client_key,
client_key.clone(),
ClientState {
connection_id,
disconnect_token,
last_activity_at: Instant::now(),
last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id },
last_inbound_seq_id: None,
status_tx,
},
);
if is_legacy_stream_id {
self.legacy_stream_ids.insert(client_id.clone(), stream_id);
}
self.send_transport_event(TransportEvent::IncomingMessage {
connection_id,
message,
})
.await
if let Err(err) = self
.send_transport_event(TransportEvent::IncomingMessage {
connection_id,
message,
})
.await
{
if let Some(client) = self.remove_client(&client_key) {
client.disconnect_token.cancel();
// The initialize send already timed out on this queue; preserve close
// delivery without blocking reconnect on the same backpressure.
drop(self.spawn_connection_closed(client.connection_id));
}
return Err(err);
}
if !is_legacy_stream_id {
self.record_inbound_message_delivery(&client_key, seq_id);
}
Ok(())
}
ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()),
ClientEvent::Ping => {
@@ -290,16 +310,9 @@ impl ClientTracker {
&mut self,
client_key: &(ClientId, StreamId),
) -> Result<(), Stopped> {
let Some(client) = self.clients.remove(client_key) else {
let Some(client) = self.remove_client(client_key) else {
return Ok(());
};
if self
.legacy_stream_ids
.get(&client_key.0)
.is_some_and(|stream_id| stream_id == &client_key.1)
{
self.legacy_stream_ids.remove(&client_key.0);
}
client.disconnect_token.cancel();
self.send_transport_event(TransportEvent::ConnectionClosed {
connection_id: client.connection_id,
@@ -307,11 +320,109 @@ impl ClientTracker {
.await
}
fn remove_client(&mut self, client_key: &(ClientId, StreamId)) -> Option<ClientState> {
let client = self.clients.remove(client_key)?;
if self
.legacy_stream_ids
.get(&client_key.0)
.is_some_and(|stream_id| stream_id == &client_key.1)
{
self.legacy_stream_ids.remove(&client_key.0);
}
Some(client)
}
async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> {
self.transport_event_tx
.send(event)
.await
.map_err(|_| Stopped)
let event = match event {
TransportEvent::ConnectionClosed { connection_id } => {
return self.send_connection_closed(connection_id).await;
}
event => event,
};
let event_name = transport_event_name(&event);
match timeout(
REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT,
self.transport_event_tx.send(event),
)
.await
{
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => {
warn!(
transport_event = event_name,
"remote control transport event receiver dropped"
);
Err(Stopped)
}
Err(_) => {
warn!(
transport_event = event_name,
timeout = ?REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT,
"timed out forwarding remote control transport event"
);
Err(Stopped)
}
}
}
fn record_inbound_message_delivery(
&mut self,
client_key: &(ClientId, StreamId),
seq_id: Option<u64>,
) {
// Timed forwarding can fail, so only dedupe retries after app-server receives it.
if let Some(seq_id) = seq_id
&& let Some(client) = self.clients.get_mut(client_key)
{
client.last_inbound_seq_id = Some(seq_id);
}
}
async fn send_connection_closed(&self, connection_id: ConnectionId) -> Result<(), Stopped> {
// Worker shutdown can abort the caller; detach the cleanup event before awaiting it.
match self.spawn_connection_closed(connection_id).await {
Ok(result) => result,
Err(err) => {
warn!(
transport_event = "connection_closed",
?err,
"remote control transport event forwarding task failed"
);
Err(Stopped)
}
}
}
fn spawn_connection_closed(
&self,
connection_id: ConnectionId,
) -> JoinHandle<Result<(), Stopped>> {
info!(
connection_id = ?connection_id,
"forwarding remote control connection closed transport event"
);
let transport_event_tx = self.transport_event_tx.clone();
tokio::spawn(async move {
transport_event_tx
.send(TransportEvent::ConnectionClosed { connection_id })
.await
.map_err(|_| {
warn!(
transport_event = "connection_closed",
"remote control transport event receiver dropped"
);
Stopped
})
})
}
}
fn transport_event_name(event: &TransportEvent) -> &'static str {
match event {
TransportEvent::ConnectionOpened { .. } => "connection_opened",
TransportEvent::ConnectionClosed { .. } => "connection_closed",
TransportEvent::IncomingMessage { .. } => "incoming_message",
}
}
@@ -370,6 +481,13 @@ mod tests {
}
}
fn initialized_notification() -> JSONRPCMessage {
JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification {
method: "initialized".to_string(),
params: None,
})
}
#[tokio::test]
async fn cancelled_outbound_task_emits_connection_closed() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
@@ -487,6 +605,256 @@ mod tests {
.expect("shutdown should not hang on blocked server forwarding");
}
#[tokio::test]
async fn non_close_transport_event_send_times_out_when_queue_stays_full() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (transport_event_tx, _transport_event_rx) = mpsc::channel(1);
let shutdown_token = CancellationToken::new();
let client_tracker =
ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token);
transport_event_tx
.send(TransportEvent::ConnectionClosed {
connection_id: next_connection_id(),
})
.await
.expect("transport event queue should accept prefill");
let send_result = client_tracker
.send_transport_event(TransportEvent::IncomingMessage {
connection_id: next_connection_id(),
message: initialized_notification(),
})
.await;
assert!(send_result.is_err());
}
#[tokio::test]
async fn incoming_message_timeout_does_not_advance_seq_id() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2);
let shutdown_token = CancellationToken::new();
let mut client_tracker =
ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token);
client_tracker
.handle_message(initialize_envelope_with_stream_id(
"client-1",
Some("stream-1"),
))
.await
.expect("initialize should open client");
let connection_id = match transport_event_rx.recv().await.expect("open event") {
TransportEvent::ConnectionOpened { connection_id, .. } => connection_id,
other => panic!("expected connection opened, got {other:?}"),
};
let _ = transport_event_rx.recv().await.expect("initialize event");
for _ in 0..2 {
transport_event_tx
.send(TransportEvent::ConnectionClosed {
connection_id: next_connection_id(),
})
.await
.expect("transport event queue should accept prefill");
}
let retry_envelope = ClientEnvelope {
event: ClientEvent::ClientMessage {
message: initialized_notification(),
},
client_id: ClientId("client-1".to_string()),
stream_id: Some(StreamId("stream-1".to_string())),
seq_id: Some(1),
cursor: None,
};
assert!(
client_tracker
.handle_message(retry_envelope.clone())
.await
.is_err()
);
for _ in 0..2 {
let _ = transport_event_rx.recv().await.expect("prefilled event");
}
client_tracker
.handle_message(retry_envelope)
.await
.expect("retry should forward after timeout");
match transport_event_rx.recv().await.expect("retried event") {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
..
} => assert_eq!(queued_connection_id, connection_id),
other => panic!("expected incoming message, got {other:?}"),
}
}
#[tokio::test]
async fn initialize_timeout_closes_open_connection() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
let shutdown_token = CancellationToken::new();
let client_tracker =
ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token);
let mut handle_message = tokio::spawn(async move {
let mut client_tracker = client_tracker;
client_tracker
.handle_message(initialize_envelope_with_stream_id(
"client-1",
Some("stream-1"),
))
.await
});
assert!(
timeout(Duration::from_millis(50), &mut handle_message)
.await
.expect("initialize timeout rollback should not wait for close delivery")
.expect("handle message task should not panic")
.is_err()
);
let connection_id = match transport_event_rx.recv().await.expect("open event") {
TransportEvent::ConnectionOpened { connection_id, .. } => connection_id,
other => panic!("expected connection opened, got {other:?}"),
};
match transport_event_rx.recv().await.expect("close event") {
TransportEvent::ConnectionClosed {
connection_id: closed_connection_id,
} => assert_eq!(closed_connection_id, connection_id),
other => panic!("expected connection closed, got {other:?}"),
}
}
#[tokio::test]
async fn close_client_waits_for_transport_event_queue_capacity() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2);
let shutdown_token = CancellationToken::new();
let mut client_tracker =
ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token);
client_tracker
.handle_message(initialize_envelope_with_stream_id(
"client-1",
Some("stream-1"),
))
.await
.expect("initialize should open client");
let connection_id = match transport_event_rx.recv().await.expect("open event") {
TransportEvent::ConnectionOpened { connection_id, .. } => connection_id,
other => panic!("expected connection opened, got {other:?}"),
};
let _ = transport_event_rx.recv().await.expect("initialize event");
for _ in 0..2 {
client_tracker
.transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: initialized_notification(),
})
.await
.expect("transport event queue should accept prefill");
}
let client_key = (
ClientId("client-1".to_string()),
StreamId("stream-1".to_string()),
);
let close_client = client_tracker.close_client(&client_key);
tokio::pin!(close_client);
assert!(
timeout(Duration::from_millis(20), &mut close_client)
.await
.is_err()
);
for _ in 0..2 {
match transport_event_rx.recv().await.expect("prefilled event") {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
..
} => assert_eq!(queued_connection_id, connection_id),
other => panic!("expected incoming message, got {other:?}"),
}
}
close_client
.await
.expect("close should forward after queue drains");
match transport_event_rx.recv().await.expect("close event") {
TransportEvent::ConnectionClosed {
connection_id: closed_connection_id,
} => assert_eq!(closed_connection_id, connection_id),
other => panic!("expected connection closed, got {other:?}"),
}
}
#[tokio::test]
async fn close_client_keeps_forwarding_after_caller_is_aborted() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2);
let shutdown_token = CancellationToken::new();
let mut client_tracker =
ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token);
client_tracker
.handle_message(initialize_envelope_with_stream_id(
"client-1",
Some("stream-1"),
))
.await
.expect("initialize should open client");
let connection_id = match transport_event_rx.recv().await.expect("open event") {
TransportEvent::ConnectionOpened { connection_id, .. } => connection_id,
other => panic!("expected connection opened, got {other:?}"),
};
let _ = transport_event_rx.recv().await.expect("initialize event");
for _ in 0..2 {
client_tracker
.transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: initialized_notification(),
})
.await
.expect("transport event queue should accept prefill");
}
let client_key = (
ClientId("client-1".to_string()),
StreamId("stream-1".to_string()),
);
let mut close_client =
tokio::spawn(async move { client_tracker.close_client(&client_key).await });
assert!(
timeout(Duration::from_millis(20), &mut close_client)
.await
.is_err()
);
close_client.abort();
let _ = close_client.await;
for _ in 0..2 {
let _ = transport_event_rx.recv().await.expect("prefilled event");
}
match timeout(Duration::from_secs(1), transport_event_rx.recv())
.await
.expect("close should be delivered")
.expect("close event")
{
TransportEvent::ConnectionClosed {
connection_id: closed_connection_id,
} => assert_eq!(closed_connection_id, connection_id),
other => panic!("expected connection closed, got {other:?}"),
}
}
#[tokio::test]
async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() {
let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY);
@@ -1,18 +1,24 @@
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RefreshRemoteServerRequest;
use super::protocol::RemoteControlTarget;
use axum::http::HeaderMap;
use codex_api::SharedAuthProvider;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::io;
use std::io::ErrorKind;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::info;
use tracing::warn;
const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096;
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30;
const REQUEST_ID_HEADER: &str = "x-request-id";
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
@@ -26,6 +32,24 @@ pub(super) struct RemoteControlEnrollment {
pub(super) environment_id: String,
pub(super) server_id: String,
pub(super) server_name: String,
pub(super) remote_control_token: Option<String>,
pub(super) expires_at: Option<OffsetDateTime>,
}
impl RemoteControlEnrollment {
pub(super) fn should_refresh_server_token(&self) -> bool {
self.remote_control_token.is_none()
|| self.expires_at.is_none_or(|expires_at| {
expires_at.unix_timestamp()
<= OffsetDateTime::now_utc().unix_timestamp()
+ REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS
})
}
pub(super) fn clear_server_token(&mut self) {
self.remote_control_token = None;
self.expires_at = None;
}
}
pub(super) struct RemoteControlConnectionAuth {
@@ -81,6 +105,8 @@ pub(super) async fn load_persisted_remote_control_enrollment(
environment_id: enrollment.environment_id,
server_id: enrollment.server_id,
server_name: enrollment.server_name,
remote_control_token: None,
expires_at: None,
}))
}
None => {
@@ -164,19 +190,33 @@ pub(crate) fn preview_remote_control_response_body(body: &[u8]) -> String {
if trimmed.is_empty() {
return "<empty>".to_string();
}
if trimmed.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES {
return trimmed.to_string();
let redacted = redact_remote_control_response_body(trimmed);
if redacted.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES {
return redacted;
}
let mut cut = REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES;
while !trimmed.is_char_boundary(cut) {
while !redacted.is_char_boundary(cut) {
cut = cut.saturating_sub(1);
}
let mut truncated = trimmed[..cut].to_string();
let mut truncated = redacted[..cut].to_string();
truncated.push_str("...");
truncated
}
fn redact_remote_control_response_body(body: &str) -> String {
let Ok(mut body_json) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
};
let Some(body_object) = body_json.as_object_mut() else {
return body.to_string();
};
if let Some(remote_control_token) = body_object.get_mut("remote_control_token") {
*remote_control_token = serde_json::Value::String("<redacted>".to_string());
}
body_json.to_string()
}
pub(crate) fn format_headers(headers: &HeaderMap) -> String {
let request_id_str = headers
.get(REQUEST_ID_HEADER)
@@ -204,58 +244,146 @@ pub(super) async fn enroll_remote_control_server(
app_server_version: env!("CARGO_PKG_VERSION"),
installation_id: installation_id.to_string(),
};
let enrollment_response = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
enroll_url,
auth,
installation_id,
&request,
"enroll",
"server enrollment",
)
.await?;
let mut enrollment = RemoteControlEnrollment {
account_id: auth.account_id.clone(),
environment_id: enrollment_response.environment_id,
server_id: enrollment_response.server_id,
server_name: server_name.to_string(),
remote_control_token: None,
expires_at: None,
};
update_remote_control_server_token(
&mut enrollment,
enroll_url,
enrollment_response.remote_control_token,
enrollment_response.expires_at,
)?;
Ok(enrollment)
}
pub(super) async fn refresh_remote_control_server(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
let refresh_url = &remote_control_target.refresh_url;
let request = RefreshRemoteServerRequest {
server_id: enrollment.server_id.clone(),
installation_id: installation_id.to_string(),
};
let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
refresh_url,
auth,
installation_id,
&request,
"refresh",
"server refresh",
)
.await?;
if refreshed.server_id != enrollment.server_id
|| refreshed.environment_id != enrollment.environment_id
{
return Err(io::Error::other(format!(
"remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
enrollment.server_id,
enrollment.environment_id,
refreshed.server_id,
refreshed.environment_id
)));
}
update_remote_control_server_token(
enrollment,
refresh_url,
refreshed.remote_control_token,
refreshed.expires_at,
)
}
async fn send_remote_control_server_request<Request, Response>(
url: &str,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
request: &Request,
action: &str,
response_kind: &str,
) -> io::Result<Response>
where
Request: Serialize,
Response: DeserializeOwned,
{
let client = build_reqwest_client();
let mut auth_headers = HeaderMap::new();
auth.auth_provider.add_auth_headers(&mut auth_headers);
let http_request = client
.post(enroll_url)
let response = client
.post(url)
.timeout(REMOTE_CONTROL_ENROLL_TIMEOUT)
.headers(auth_headers)
.header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)
.header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id)
.json(&request);
let response = http_request.send().await.map_err(|err| {
io::Error::other(format!(
"failed to enroll remote control server at `{enroll_url}`: {err}"
))
})?;
.json(request)
.send()
.await
.map_err(|err| {
io::Error::other(format!(
"failed to {action} remote control server at `{url}`: {err}"
))
})?;
let headers = response.headers().clone();
let status = response.status();
let body = response.bytes().await.map_err(|err| {
io::Error::other(format!(
"failed to read remote control enrollment response from `{enroll_url}`: {err}"
"failed to read remote control {response_kind} response from `{url}`: {err}"
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let headers_str = format_headers(&headers);
let error_kind = if matches!(status.as_u16(), 401 | 403) {
ErrorKind::PermissionDenied
} else {
ErrorKind::Other
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
format!(
"remote control server enrollment failed at `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}"
"remote control {response_kind} failed at `{url}`: HTTP {status}, {headers_str}, body: {body_preview}"
),
));
}
let enrollment = serde_json::from_slice::<EnrollRemoteServerResponse>(&body).map_err(|err| {
serde_json::from_slice::<Response>(&body).map_err(|err| {
let headers_str = format_headers(&headers);
io::Error::other(format!(
"failed to parse remote control enrollment response from `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}"
"failed to parse remote control {response_kind} response from `{url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}"
))
})
}
fn update_remote_control_server_token(
enrollment: &mut RemoteControlEnrollment,
url: &str,
token: String,
expires_at: String,
) -> io::Result<()> {
let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339).map_err(|err| {
io::Error::other(format!(
"failed to parse remote control server token expiry from `{url}`: {err}"
))
})?;
Ok(RemoteControlEnrollment {
account_id: auth.account_id.clone(),
environment_id: enrollment.environment_id,
server_id: enrollment.server_id,
server_name: server_name.to_string(),
})
enrollment.remote_control_token = Some(token);
enrollment.expires_at = Some(expires_at);
Ok(())
}
#[cfg(test)]
@@ -281,6 +409,40 @@ mod tests {
.expect("state runtime should initialize")
}
#[test]
fn remote_control_enrollment_refreshes_server_token_before_expiry() {
let expires_soon = RemoteControlEnrollment {
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
remote_control_token: Some("expires-soon".to_string()),
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)),
};
let expires_later = RemoteControlEnrollment {
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(31)),
remote_control_token: Some("expires-later".to_string()),
..expires_soon.clone()
};
assert!(expires_soon.should_refresh_server_token());
assert!(!expires_later.should_refresh_server_token());
}
#[test]
fn preview_remote_control_response_body_redacts_server_token() {
assert_eq!(
serde_json::from_str::<serde_json::Value>(&preview_remote_control_response_body(
br#"{"server_id":"srv_e_test","remote_control_token":"secret"}"#
))
.expect("redacted response preview should stay valid json"),
json!({
"server_id": "srv_e_test",
"remote_control_token": "<redacted>",
})
);
}
#[tokio::test]
async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() {
let codex_home = TempDir::new().expect("temp dir should create");
@@ -295,12 +457,16 @@ mod tests {
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
remote_control_token: None,
expires_at: None,
};
let second_enrollment = RemoteControlEnrollment {
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
server_name: "second-server".to_string(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
@@ -371,12 +537,16 @@ mod tests {
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
remote_control_token: None,
expires_at: None,
};
let second_enrollment = RemoteControlEnrollment {
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
server_name: "second-server".to_string(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
@@ -448,7 +618,8 @@ mod tests {
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let enroll_url = remote_control_target.enroll_url.clone();
let response_body = json!({
"error": "not enrolled",
"server_id": "srv_e_test",
"environment_id": "env_test",
});
let expected_body = response_body.to_string();
let server_task = tokio::spawn(async move {
@@ -472,7 +643,7 @@ mod tests {
assert_eq!(
err.to_string(),
format!(
"failed to parse remote control enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: <none>, cf-ray: <none>, body: {expected_body}, decode error: missing field `server_id` at line 1 column {}",
"failed to parse remote control server enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: <none>, cf-ray: <none>, body: {expected_body}, decode error: missing field `remote_control_token` at line 1 column {}",
expected_body.len()
)
);
@@ -19,16 +19,20 @@ use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
use futures::FutureExt;
use gethostname::gethostname;
use std::error::Error;
use std::fmt;
use std::io;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::info;
use tracing::warn;
pub struct RemoteControlStartConfig {
@@ -73,13 +77,21 @@ impl RemoteControlHandle {
return Err(RemoteControlUnavailable);
}
self.enabled_tx.send_if_modified(|state| {
let enabled_changed = self.enabled_tx.send_if_modified(|state| {
let changed = !*state;
*state = true;
changed
});
let status = self.status();
info!(
enabled_changed,
current_status = ?status.status,
environment_id = ?status.environment_id,
installation_id = %status.installation_id,
server_name = %status.server_name,
"remote control enable requested"
);
if matches!(
status.status,
RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting
@@ -91,12 +103,21 @@ impl RemoteControlHandle {
}
pub fn disable(&self) -> RemoteControlStatusChangedNotification {
self.enabled_tx.send_if_modified(|state| {
let enabled_changed = self.enabled_tx.send_if_modified(|state| {
let changed = *state;
*state = false;
changed
});
let status = self.status();
info!(
enabled_changed,
current_status = ?status.status,
environment_id = ?status.environment_id,
installation_id = %status.installation_id,
server_name = %status.server_name,
"remote control disable requested"
);
self.publish_status(RemoteControlConnectionStatus::Disabled)
}
@@ -112,6 +133,7 @@ impl RemoteControlHandle {
&self,
connection_status: RemoteControlConnectionStatus,
) -> RemoteControlStatusChangedNotification {
let mut status_change = None;
self.status_tx.send_if_modified(|status| {
let next_status =
remote_control_status_with_connection_status(status, connection_status);
@@ -119,9 +141,21 @@ impl RemoteControlHandle {
return false;
}
status_change = Some((status.clone(), next_status.clone()));
*status = next_status;
true
});
if let Some((previous_status, next_status)) = status_change {
info!(
previous_status = ?previous_status.status,
next_status = ?next_status.status,
previous_environment_id = ?previous_status.environment_id,
next_environment_id = ?next_status.environment_id,
installation_id = %next_status.installation_id,
server_name = %next_status.server_name,
"remote control handle status changed"
);
}
self.status()
}
}
@@ -165,6 +199,8 @@ pub async fn start_remote_control(
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let server_name = gethostname().to_string_lossy().trim().to_string();
let remote_control_url = config.remote_control_url;
let installation_id = config.installation_id;
let initial_status = RemoteControlStatusChangedNotification {
status: if initial_enabled {
RemoteControlConnectionStatus::Connecting
@@ -172,16 +208,35 @@ pub async fn start_remote_control(
RemoteControlConnectionStatus::Disabled
},
server_name: server_name.clone(),
installation_id: config.installation_id.clone(),
installation_id: installation_id.clone(),
environment_id: None,
};
let (status_tx, _status_rx) = watch::channel(initial_status);
let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone());
info!(
remote_control_url = %remote_control_url,
installation_id = %installation_id,
server_name = %server_name,
state_db_available,
initial_enabled,
"starting app-server remote control websocket task"
);
let remote_control_url_for_log = remote_control_url.clone();
let installation_id_for_log = installation_id.clone();
let server_name_for_log = server_name.clone();
let shutdown_token_for_log = shutdown_token.clone();
let join_handle = tokio::spawn(async move {
RemoteControlWebsocket::new(
info!(
remote_control_url = %remote_control_url_for_log,
installation_id = %installation_id_for_log,
server_name = %server_name_for_log,
initial_enabled,
"app-server remote control websocket task started"
);
let websocket_task = RemoteControlWebsocket::new(
websocket::RemoteControlWebsocketConfig {
remote_control_url: config.remote_control_url,
installation_id: config.installation_id,
remote_control_url,
installation_id,
remote_control_target,
server_name,
},
@@ -194,8 +249,38 @@ pub async fn start_remote_control(
shutdown_token,
enabled_rx,
)
.run(app_server_client_name_rx)
.await;
.run(app_server_client_name_rx);
match AssertUnwindSafe(websocket_task).catch_unwind().await {
Ok(()) => {
let shutdown_requested = shutdown_token_for_log.is_cancelled();
if shutdown_requested {
info!(
remote_control_url = %remote_control_url_for_log,
installation_id = %installation_id_for_log,
server_name = %server_name_for_log,
shutdown_requested,
"app-server remote control websocket task exited"
);
} else {
warn!(
remote_control_url = %remote_control_url_for_log,
installation_id = %installation_id_for_log,
server_name = %server_name_for_log,
shutdown_requested,
"app-server remote control websocket task exited without shutdown"
);
}
}
Err(panic) => {
error!(
remote_control_url = %remote_control_url_for_log,
installation_id = %installation_id_for_log,
server_name = %server_name_for_log,
"app-server remote control websocket task panicked"
);
std::panic::resume_unwind(panic);
}
}
});
Ok((
@@ -11,6 +11,7 @@ use url::Url;
pub(super) struct RemoteControlTarget {
pub(super) websocket_url: String,
pub(super) enroll_url: String,
pub(super) refresh_url: String,
}
#[derive(Debug, Serialize)]
@@ -26,6 +27,14 @@ pub(super) struct EnrollRemoteServerRequest {
pub(super) struct EnrollRemoteServerResponse {
pub(super) server_id: String,
pub(super) environment_id: String,
pub(super) remote_control_token: String,
pub(super) expires_at: String,
}
#[derive(Debug, Serialize)]
pub(super) struct RefreshRemoteServerRequest {
pub(super) server_id: String,
pub(super) installation_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -177,6 +186,9 @@ pub(super) fn normalize_remote_control_url(
let enroll_url = remote_control_url
.join("wham/remote/control/server/enroll")
.map_err(map_url_parse_error)?;
let refresh_url = remote_control_url
.join("wham/remote/control/server/refresh")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
@@ -194,6 +206,7 @@ pub(super) fn normalize_remote_control_url(
Ok(RemoteControlTarget {
websocket_url: websocket_url.to_string(),
enroll_url: enroll_url.to_string(),
refresh_url: refresh_url.to_string(),
})
}
@@ -212,6 +225,8 @@ mod tests {
.to_string(),
enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll"
.to_string(),
refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh"
.to_string(),
}
);
assert_eq!(
@@ -224,6 +239,9 @@ mod tests {
enroll_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll"
.to_string(),
refresh_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh"
.to_string(),
}
);
}
@@ -238,6 +256,8 @@ mod tests {
.to_string(),
enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll"
.to_string(),
refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh"
.to_string(),
}
);
assert_eq!(
@@ -248,6 +268,9 @@ mod tests {
.to_string(),
enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll"
.to_string(),
refresh_url:
"https://localhost:8443/backend-api/wham/remote/control/server/refresh"
.to_string(),
}
);
}
@@ -58,6 +58,9 @@ use tokio_tungstenite::tungstenite;
use tokio_util::sync::CancellationToken;
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token";
const TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN: &str = "Refreshed Remote Control Token";
const TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT: &str = "2999-01-01T00:00:00Z";
fn remote_control_auth_manager() -> Arc<AuthManager> {
auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
@@ -125,6 +128,19 @@ fn test_server_name() -> String {
gethostname().to_string_lossy().trim().to_string()
}
fn remote_control_server_token_response(
server_id: &str,
environment_id: &str,
remote_control_token: &str,
) -> serde_json::Value {
json!({
"server_id": server_id,
"environment_id": environment_id,
"remote_control_token": remote_control_token,
"expires_at": TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT,
})
}
async fn expect_remote_control_status(
status_rx: &mut watch::Receiver<RemoteControlStatusChangedNotification>,
expected_status: Option<RemoteControlConnectionStatus>,
@@ -203,7 +219,11 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
);
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let mut websocket = accept_remote_control_connection(&listener).await;
@@ -483,17 +503,31 @@ async fn remote_control_transport_reconnects_after_disconnect() {
);
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let mut first_websocket = accept_remote_control_connection(&listener).await;
let (first_handshake_request, mut first_websocket) =
accept_remote_control_backend_connection(&listener).await;
assert_eq!(
first_handshake_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
first_websocket
.close(None)
.await
.expect("first websocket should close");
drop(first_websocket);
let mut second_websocket = accept_remote_control_connection(&listener).await;
let (second_handshake_request, mut second_websocket) =
accept_remote_control_backend_connection(&listener).await;
assert_eq!(
second_handshake_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
@@ -537,6 +571,91 @@ async fn remote_control_transport_reconnects_after_disconnect() {
let _ = remote_task.await;
}
#[tokio::test]
async fn remote_control_transport_refreshes_server_token_after_websocket_unauthorized() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let (transport_event_tx, _transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown_token = CancellationToken::new();
let (remote_task, remote_handle) = start_remote_control(
RemoteControlStartConfig {
remote_control_url,
installation_id: TEST_INSTALLATION_ID.to_string(),
},
Some(remote_control_state_runtime(&codex_home).await),
remote_control_auth_manager(),
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
/*initial_enabled*/ true,
)
.await
.expect("remote control should start");
let mut status_rx = remote_handle.status_receiver();
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_json(
enroll_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let websocket_request = accept_http_request(&listener).await;
assert_eq!(
websocket_request.request_line,
"GET /backend-api/wham/remote/control/server HTTP/1.1"
);
assert_eq!(
websocket_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_status(websocket_request.stream, "401 Unauthorized", "").await;
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
Some("env_test"),
)
.await;
assert_eq!(
handshake_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
shutdown_token.cancel();
let _ = remote_task.await;
}
#[tokio::test]
async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() {
let (transport_event_tx, _transport_event_rx) =
@@ -696,7 +815,11 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
);
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let mut first_websocket = accept_remote_control_connection(&listener).await;
@@ -801,7 +924,11 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
let enroll_request = accept_http_request(&listener).await;
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let mut first_websocket = accept_remote_control_connection(&listener).await;
@@ -1008,7 +1135,11 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
);
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
@@ -1026,13 +1157,13 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
);
assert_eq!(
handshake_request.headers.get("authorization"),
Some(&"Bearer Access Token".to_string())
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
assert_eq!(
handshake_request
.headers
.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER),
Some(&"account_id".to_string())
None
);
assert_eq!(
handshake_request
@@ -1172,7 +1303,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
}
#[tokio::test]
async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() {
async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecting() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
@@ -1186,6 +1317,8 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
server_name: "persisted-server".to_string(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1215,6 +1348,33 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling
.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"
);
assert_eq!(
refresh_request.headers.get("authorization"),
Some(&"Bearer Access Token".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&refresh_request.body)
.expect("refresh body should deserialize"),
json!({
"server_id": persisted_enrollment.server_id.clone(),
"installation_id": TEST_INSTALLATION_ID,
})
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
&persisted_enrollment.server_id,
&persisted_enrollment.environment_id,
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
assert_eq!(
handshake_request.path,
@@ -1224,6 +1384,12 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling
handshake_request.headers.get("x-codex-server-id"),
Some(&persisted_enrollment.server_id)
);
assert_eq!(
handshake_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
assert_eq!(
load_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1256,6 +1422,8 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
server_name: "persisted-server".to_string(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1291,6 +1459,20 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
.expect_err("remote control should wait for the stdio client name");
let _ = app_server_client_name_tx.send(app_server_client_name.to_string());
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(
&persisted_enrollment.server_id,
&persisted_enrollment.environment_id,
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
assert_eq!(
handshake_request.headers.get("x-codex-server-id"),
@@ -1328,6 +1510,8 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
environment_id: "env_ready".to_string(),
server_id: "srv_e_ready".to_string(),
server_name: expected_server_name,
remote_control_token: None,
expires_at: None,
};
let (transport_event_tx, _transport_event_rx) =
@@ -1369,10 +1553,11 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
);
respond_with_json(
enroll_request.stream,
json!({
"server_id": expected_enrollment.server_id,
"environment_id": expected_enrollment.environment_id,
}),
remote_control_server_token_response(
&expected_enrollment.server_id,
&expected_enrollment.environment_id,
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
@@ -1387,7 +1572,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
}
#[tokio::test]
async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() {
async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollment() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
@@ -1402,12 +1587,16 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
server_name: expected_server_name,
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1438,6 +1627,138 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
.expect("remote control should start");
let mut status_rx = remote_handle.status_receiver();
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"
);
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
Some("env_stale"),
)
.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!(
enroll_request.request_line,
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
);
respond_with_json(
enroll_request.stream,
remote_control_server_token_response(
&refreshed_enrollment.server_id,
&refreshed_enrollment.environment_id,
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
Some("env_refreshed"),
)
.await;
assert_eq!(
handshake_request.headers.get("x-codex-server-id"),
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)
);
shutdown_token.cancel();
let _ = remote_task.await;
}
#[tokio::test]
async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let 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 expected_server_name = gethostname().to_string_lossy().trim().to_string();
let stale_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
server_name: expected_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),
)
.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,
/*initial_enabled*/ true,
)
.await
.expect("remote control should start");
let mut status_rx = remote_handle.status_receiver();
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(
&stale_enrollment.server_id,
&stale_enrollment.environment_id,
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let websocket_request = accept_http_request(&listener).await;
assert_eq!(
websocket_request.request_line,
@@ -1468,10 +1789,11 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
);
respond_with_json(
enroll_request.stream,
json!({
"server_id": refreshed_enrollment.server_id,
"environment_id": refreshed_enrollment.environment_id,
}),
remote_control_server_token_response(
&refreshed_enrollment.server_id,
&refreshed_enrollment.environment_id,
TEST_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
File diff suppressed because it is too large Load Diff
@@ -347,7 +347,7 @@ async fn run_websocket_inbound_loop<M, StreamError>(
incoming_message = websocket_reader.next() => {
match incoming_message {
Some(Ok(message)) => match message.into_incoming() {
Some(IncomingWebSocketMessage::Text(text)) => {
Some(IncomingWebSocketMessage::Text(text))
if !forward_incoming_message(
&transport_event_tx,
&writer_tx_for_reader,
@@ -355,10 +355,10 @@ async fn run_websocket_inbound_loop<M, StreamError>(
&text,
)
.await
{
break;
}
=> {
break;
}
Some(IncomingWebSocketMessage::Text(_)) => {}
Some(IncomingWebSocketMessage::Ping(payload)) => {
match writer_control_tx.try_send(M::pong(payload)) {
Ok(()) => {}
+5
View File
@@ -54,7 +54,9 @@ codex-backend-client = { workspace = true }
codex-file-search = { workspace = true }
codex-chatgpt = { workspace = true }
codex-login = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-memories-extension = { workspace = true }
codex-web-search-extension = { workspace = true }
codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
codex-model-provider = { workspace = true }
@@ -93,6 +95,9 @@ tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
uuid = { workspace = true, features = ["serde", "v7"] }
[target.'cfg(windows)'.dependencies]
codex-windows-sandbox = { workspace = true }
[dev-dependencies]
app_test_support = { workspace = true }
axum = { workspace = true, default-features = false, features = [
+28 -6
View File
@@ -23,7 +23,7 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
Supported transports:
- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL)
- stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL)
- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**)
- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake
- off (`--listen off`): do not expose a local transport
@@ -158,9 +158,9 @@ Example with notification opt-out:
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
- `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`.
@@ -192,7 +192,7 @@ Example with notification opt-out:
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
@@ -216,7 +216,7 @@ Example with notification opt-out:
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`.
- `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly.
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
@@ -281,6 +281,8 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage.
Experimental clients that want the live resume subscription plus a turns page in one round trip can pass `initialTurnsPage`. It accepts the same `limit`, `sortDirection`, and `itemsView` controls as `thread/turns/list`; omitted controls use its defaults. The response includes `initialTurnsPage` with `nextCursor` and `backwardsCursor` for follow-up pagination.
By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead.
Example:
@@ -297,6 +299,24 @@ Example:
"excludeTurns": true
} }
{ "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } }
{ "method": "thread/resume", "id": 13, "params": {
"threadId": "thr_123",
"excludeTurns": true,
"initialTurnsPage": {
"limit": 20,
"sortDirection": "desc",
"itemsView": "summary"
}
} }
{ "id": 13, "result": {
"thread": { "id": "thr_123", "turns": [], … },
"initialTurnsPage": {
"data": [ ... ],
"nextCursor": "older-turns-cursor-or-null",
"backwardsCursor": "newer-turns-cursor-or-null"
}
} }
```
To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only:
@@ -642,6 +662,7 @@ You can optionally specify config overrides on the new turn. If specified, these
```json
{ "method": "turn/start", "id": 30, "params": {
"threadId": "thr_123",
"clientUserMessageId": "client_msg_123",
"input": [ { "type": "text", "text": "Run tests" } ],
// Below are optional config overrides
"cwd": "/Users/me/project",
@@ -840,6 +861,7 @@ not emit `turn/started` and does not accept thread settings overrides.
```json
{ "method": "turn/steer", "id": 32, "params": {
"threadId": "thr_123",
"clientUserMessageId": "client_msg_124",
"input": [ { "type": "text", "text": "Actually focus on failing tests first." } ],
"expectedTurnId": "turn_456"
} }
@@ -1225,7 +1247,7 @@ Today both notifications carry an empty `items` array even when item events were
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
- `userMessage` — `{id, clientId, content}` where `clientId` is the optional `clientUserMessageId` supplied to `turn/start` or `turn/steer`, and `content` is a list of user inputs (`text`, `image`, or `localImage`).
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental).
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
@@ -2153,6 +2153,7 @@ mod tests {
let created_at = Utc::now();
let history_items = vec![
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "before rollback".to_string(),
images: None,
local_images: Vec::new(),
@@ -3228,6 +3229,7 @@ mod tests {
state.track_current_turn_event(
"turn-1",
&EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent {
client_id: None,
message: "already tracked".to_string(),
images: None,
local_images: Vec::new(),
+6 -2
View File
@@ -697,12 +697,13 @@ mod tests {
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
ExecRequest::new(
vec!["cmd".to_string()],
cwd,
cwd.clone(),
HashMap::new(),
/*network*/ None,
ExecExpiration::DefaultTimeout,
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::WindowsRestrictedToken,
vec![cwd],
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
PermissionProfile::read_only(),
@@ -819,6 +820,7 @@ mod tests {
ExecExpiration::Cancellation(CancellationToken::new()),
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::None,
vec![cwd.clone()],
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
PermissionProfile::read_only(),
@@ -887,6 +889,7 @@ mod tests {
};
let cancellation = CancellationToken::new();
let cancel = cancellation.clone();
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
manager
.start(StartCommandExecParams {
@@ -898,7 +901,7 @@ mod tests {
process_id: Some("proc-101".to_string()),
exec_request: ExecRequest::new(
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
AbsolutePathBuf::current_dir().expect("current dir"),
cwd.clone(),
HashMap::new(),
/*network*/ None,
ExecExpiration::TimeoutOrCancellation {
@@ -907,6 +910,7 @@ mod tests {
},
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::None,
vec![cwd],
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
PermissionProfile::read_only(),
+10 -2
View File
@@ -216,15 +216,23 @@ impl ConfigManager {
&self,
cli_overrides: &[(String, TomlValue)],
request_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
mut typesafe_overrides: ConfigOverrides,
fallback_cwd: Option<PathBuf>,
) -> std::io::Result<Config> {
let mut request_overrides = request_overrides.unwrap_or_default();
if let Some(value) = request_overrides.remove("bypass_hook_trust") {
typesafe_overrides.bypass_hook_trust = Some(value.as_bool().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"`bypass_hook_trust` override must be a boolean",
)
})?);
}
let merged_cli_overrides = cli_overrides
.iter()
.cloned()
.chain(
request_overrides
.unwrap_or_default()
.into_iter()
.map(|(key, value)| (key, json_to_toml(value))),
)
+128 -3
View File
@@ -1,27 +1,71 @@
use std::sync::Arc;
use std::sync::Weak;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_extension_api::AgentSpawnFuture;
use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_login::AuthManager;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
pub(crate) fn thread_extensions<S>(guardian_agent_spawner: S) -> Arc<ExtensionRegistry<Config>>
use crate::outgoing_message::OutgoingMessageSender;
pub(crate) fn thread_extensions<S>(
guardian_agent_spawner: S,
event_sink: Arc<dyn ExtensionEventSink>,
auth_manager: Arc<AuthManager>,
) -> Arc<ExtensionRegistry<Config>>
where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
{
let mut builder = ExtensionRegistryBuilder::<Config>::new();
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_web_search_extension::install(&mut builder, auth_manager.clone());
codex_image_generation_extension::install(&mut builder, auth_manager);
Arc::new(builder.build())
}
pub(crate) fn app_server_extension_event_sink(
outgoing: Arc<OutgoingMessageSender>,
) -> Arc<dyn ExtensionEventSink> {
Arc::new(AppServerExtensionEventSink { outgoing })
}
struct AppServerExtensionEventSink {
outgoing: Arc<OutgoingMessageSender>,
}
impl ExtensionEventSink for AppServerExtensionEventSink {
fn emit(&self, event: Event) {
match event.msg {
EventMsg::ThreadGoalUpdated(thread_goal_event) => {
self.outgoing
.try_send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_goal_event.thread_id.to_string(),
turn_id: thread_goal_event.turn_id,
goal: thread_goal_event.goal.into(),
},
));
}
msg => {
tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event");
}
}
}
}
pub(crate) fn guardian_agent_spawner(
thread_manager: Weak<ThreadManager>,
) -> impl AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> {
@@ -39,3 +83,84 @@ pub(crate) fn guardian_agent_spawner(
})
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc;
use tokio::time::timeout;
use super::*;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
#[tokio::test]
async fn app_server_event_sink_forwards_thread_goal_updates() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let sink = app_server_extension_event_sink(outgoing);
let thread_id = ThreadId::default();
sink.emit(Event {
id: "call-1".to_string(),
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
thread_id,
turn_id: Some("turn-1".to_string()),
goal: ThreadGoal {
thread_id,
objective: "wire extension events".to_string(),
status: ThreadGoalStatus::Active,
token_budget: Some(123),
tokens_used: 45,
time_used_seconds: 6,
created_at: 7,
updated_at: 8,
},
}),
});
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
.await
.expect("timed out waiting for forwarded extension event")
.expect("outgoing channel closed unexpectedly");
let OutgoingEnvelope::Broadcast { message } = envelope else {
panic!("expected broadcast notification");
};
let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(
notification,
)) = message
else {
panic!("expected thread goal updated notification");
};
assert_eq!(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
goal: AppServerThreadGoal {
thread_id: thread_id.to_string(),
objective: "wire extension events".to_string(),
status: AppServerThreadGoalStatus::Active,
token_budget: Some(123),
tokens_used: 45,
time_used_seconds: 6,
created_at: 7,
updated_at: 8,
},
},
notification
);
}
}
+8 -3
View File
@@ -114,6 +114,7 @@ mod tests {
use codex_core::init_state_db;
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::NoopExtensionEventSink;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::protocol::SessionSource;
@@ -183,12 +184,16 @@ mod tests {
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
&good_config,
auth_manager,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
thread_extensions(
guardian_agent_spawner(thread_manager.clone()),
Arc::new(NoopExtensionEventSink),
auth_manager.clone(),
),
/*analytics_events_client*/ None,
thread_store,
Arc::clone(&thread_store),
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
+6 -1
View File
@@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::invalid_request;
use crate::extensions::app_server_extension_event_sink;
use crate::extensions::guardian_agent_spawner;
use crate::extensions::thread_extensions;
use crate::fs_watch::FsWatchManager;
@@ -310,7 +311,11 @@ impl MessageProcessor {
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(guardian_agent_spawner(thread_manager.clone())),
thread_extensions(
guardian_agent_spawner(thread_manager.clone()),
app_server_extension_event_sink(outgoing.clone()),
auth_manager.clone(),
),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
@@ -653,11 +653,13 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
params: TurnStartParams {
environments: None,
thread_id,
client_user_message_id: None,
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
additional_context: None,
cwd: None,
runtime_workspace_roots: None,
approval_policy: None,
+12 -2
View File
@@ -456,8 +456,8 @@ impl OutgoingMessageSender {
) -> Vec<ServerRequest> {
let request_id_to_callback = self.request_id_to_callback.lock().await;
let mut requests = request_id_to_callback
.iter()
.filter_map(|(_, entry)| {
.values()
.filter_map(|entry| {
(entry.thread_id == Some(thread_id)).then_some(entry.request.clone())
})
.collect::<Vec<_>>();
@@ -555,6 +555,16 @@ impl OutgoingMessageSender {
.await;
}
pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) {
tracing::trace!("app-server event: {notification}");
let outgoing_message = OutgoingMessage::AppServerNotification(notification);
if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
}) {
warn!("failed to send server notification to client without waiting: {err:?}");
}
}
pub(crate) async fn send_server_notification_to_connections(
&self,
connection_ids: &[ConnectionId],
@@ -25,6 +25,8 @@ use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AdditionalContextEntry;
use codex_app_server_protocol::AdditionalContextKind;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppSummary;
@@ -211,6 +213,7 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
@@ -421,6 +424,7 @@ use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Error as IoError;
@@ -203,6 +203,7 @@ impl CommandExecRequestProcessor {
network_proxy_spec,
network_proxy_permission_profile,
managed_network_requirements_enabled,
windows_sandbox_workspace_roots,
) = if let Some(permission_profile) = permission_profile {
let overrides = ConfigOverrides {
cwd: Some(cwd.to_path_buf()),
@@ -230,6 +231,7 @@ impl CommandExecRequestProcessor {
config.permissions.network.clone(),
config.permissions.permission_profile().clone(),
config.managed_network_requirements_enabled(),
config.effective_workspace_roots(),
)
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
self.config
@@ -255,6 +257,7 @@ impl CommandExecRequestProcessor {
self.config.permissions.network.clone(),
self.config.permissions.permission_profile().clone(),
self.config.managed_network_requirements_enabled(),
self.config.effective_workspace_roots(),
)
} else {
(
@@ -262,6 +265,7 @@ impl CommandExecRequestProcessor {
self.config.permissions.network.clone(),
self.config.permissions.permission_profile().clone(),
self.config.managed_network_requirements_enabled(),
self.config.effective_workspace_roots(),
)
};
let started_network_proxy = match network_proxy_spec.as_ref() {
@@ -318,6 +322,7 @@ impl CommandExecRequestProcessor {
exec_params,
&effective_permission_profile,
&sandbox_cwd,
windows_sandbox_workspace_roots.as_slice(),
&codex_linux_sandbox_exe,
use_legacy_landlock,
)
@@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"mentions_v2",
"plugins",
"remote_control",
"remote_plugin",
"tool_suggest",
"tool_call_mcp_elicitation",
];
@@ -610,7 +611,7 @@ fn map_network_unix_socket_permission_to_api(
) -> NetworkUnixSocketPermission {
match permission {
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
codex_config::NetworkUnixSocketPermissionToml::Deny => NetworkUnixSocketPermission::Deny,
}
}
@@ -1,4 +1,6 @@
use super::*;
#[cfg(target_os = "windows")]
use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME;
#[derive(Clone)]
pub(crate) struct FeedbackRequestProcessor {
@@ -186,6 +188,12 @@ impl FeedbackRequestProcessor {
)),
});
}
if let Some(sandbox_log_attachment) =
windows_sandbox_log_attachment(&self.config.codex_home)
&& seen_attachment_paths.insert(sandbox_log_attachment.path.clone())
{
attachment_paths.push(sandbox_log_attachment);
}
}
if let Some(extra_log_files) = extra_log_files {
for extra_log_file in extra_log_files {
@@ -264,3 +272,46 @@ impl FeedbackRequestProcessor {
fn auto_review_rollout_filename(thread_id: ThreadId) -> String {
format!("auto-review-rollout-{thread_id}.jsonl")
}
#[cfg(target_os = "windows")]
fn windows_sandbox_log_attachment(codex_home: &Path) -> Option<FeedbackAttachmentPath> {
let sandbox_log_path = codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home);
sandbox_log_path
.is_file()
.then_some(FeedbackAttachmentPath {
path: sandbox_log_path,
attachment_filename_override: Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()),
})
}
#[cfg(not(target_os = "windows"))]
fn windows_sandbox_log_attachment(_codex_home: &Path) -> Option<FeedbackAttachmentPath> {
None
}
#[cfg(all(test, target_os = "windows"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn windows_sandbox_log_attachment_uses_current_log() {
let codex_home = tempfile::tempdir().expect("create tempdir");
let sandbox_dir = codex_windows_sandbox::sandbox_dir(codex_home.path());
std::fs::create_dir_all(&sandbox_dir).expect("create sandbox dir");
let sandbox_log_path =
codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home.path());
std::fs::write(&sandbox_log_path, "sandbox log").expect("write sandbox log");
let attachment = windows_sandbox_log_attachment(codex_home.path())
.map(|attachment| (attachment.path, attachment.attachment_filename_override));
assert_eq!(
attachment,
Some((
sandbox_log_path,
Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string())
))
);
}
}
@@ -199,15 +199,25 @@ impl McpRequestProcessor {
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let config = match params.thread_id.as_deref() {
Some(thread_id) => {
let (_, thread) = self.load_thread(thread_id).await?;
let thread_config = thread.config().await;
self.config_manager
.load_latest_config_for_thread(thread_config.as_ref())
.await
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?
}
None => self.load_latest_config(/*fallback_cwd*/ None).await?,
};
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// This threadless status path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
// environment stdio MCPs must declare their own absolute cwd.
// This status path has no turn-selected environment. Use config cwd
// as the local stdio fallback; named environment stdio MCPs must
// declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
@@ -266,6 +276,7 @@ impl McpRequestProcessor {
.await;
let McpServerStatusSnapshot {
server_infos,
tools_by_server,
resources,
resource_templates,
@@ -305,6 +316,7 @@ impl McpRequestProcessor {
.iter()
.map(|name| McpServerStatus {
name: name.clone(),
server_info: server_infos.get(name).cloned(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
resources: resources.get(name).cloned().unwrap_or_default(),
resource_templates: resource_templates.get(name).cloned().unwrap_or_default(),
@@ -565,8 +565,28 @@ pub(super) async fn handle_pending_thread_resume_request(
has_live_in_progress_turn,
);
let token_usage_thread = pending.include_turns.then(|| thread.clone());
let mut initial_turns_page = if let Some(params) = pending.initial_turns_page.as_ref() {
match super::thread_processor::build_thread_resume_initial_turns_page(
&pending.history_items,
thread.status.clone(),
has_live_in_progress_turn,
active_turn,
params,
) {
Ok(page) => Some(page),
Err(error) => {
outgoing.send_error(request_id, error).await;
return;
}
}
} else {
None
};
if pending.redact_resume_payloads {
redact_thread_resume_payloads(&mut thread);
redact_thread_resume_payloads(&mut thread.turns);
if let Some(initial_turns_page) = initial_turns_page.as_mut() {
redact_thread_resume_payloads(&mut initial_turns_page.data);
}
}
{
@@ -635,6 +655,7 @@ pub(super) async fn handle_pending_thread_resume_request(
sandbox,
active_permission_profile,
reasoning_effort,
initial_turns_page,
};
outgoing.send_response(request_id, response).await;
// Match cold resume: metadata-only resume should attach the listener without
@@ -1513,9 +1513,13 @@ impl ThreadRequestProcessor {
.clone()
.ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?;
state_db.clear_memory_data().await.map_err(|err| {
internal_error(format!("failed to clear memory rows in state db: {err}"))
})?;
state_db
.memories()
.clear_memory_data()
.await
.map_err(|err| {
internal_error(format!("failed to clear memory rows in memories db: {err}"))
})?;
clear_memory_roots_contents(&self.config.codex_home)
.await
@@ -2237,8 +2241,6 @@ impl ThreadRequestProcessor {
sort_direction,
items_view,
} = params;
let items_view = items_view.unwrap_or(TurnItemsView::Summary);
let thread_uuid = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
@@ -2266,60 +2268,20 @@ impl ThreadRequestProcessor {
} else {
None
};
let mut turns = reconstruct_thread_turns_for_turns_list(
build_thread_turns_page_response(
&items,
self.thread_watch_manager
.loaded_status_for_thread(&thread_uuid.to_string())
.await,
has_live_running_thread,
active_turn,
);
for turn in &mut turns {
match items_view {
TurnItemsView::NotLoaded => {
turn.items.clear();
turn.items_view = TurnItemsView::NotLoaded;
}
TurnItemsView::Summary => {
let first_user_message = turn
.items
.iter()
.find(|item| matches!(item, ThreadItem::UserMessage { .. }))
.cloned();
let final_agent_message = turn
.items
.iter()
.rev()
.find(|item| matches!(item, ThreadItem::AgentMessage { .. }))
.cloned();
turn.items = match (first_user_message, final_agent_message) {
(Some(user_message), Some(agent_message))
if user_message.id() != agent_message.id() =>
{
vec![user_message, agent_message]
}
(Some(user_message), _) => vec![user_message],
(None, Some(agent_message)) => vec![agent_message],
(None, None) => Vec::new(),
};
turn.items_view = TurnItemsView::Summary;
}
TurnItemsView::Full => {
turn.items_view = TurnItemsView::Full;
}
}
}
let page = paginate_thread_turns(
turns,
cursor.as_deref(),
limit,
sort_direction.unwrap_or(SortDirection::Desc),
)?;
Ok(ThreadTurnsListResponse {
data: page.turns,
next_cursor: page.next_cursor,
backwards_cursor: page.backwards_cursor,
})
ThreadTurnsPageOptions {
cursor: cursor.as_deref(),
limit,
sort_direction: sort_direction.unwrap_or(SortDirection::Desc),
items_view: items_view.unwrap_or(TurnItemsView::Summary),
},
)
}
async fn load_thread_turns_list_history(
@@ -2527,6 +2489,7 @@ impl ThreadRequestProcessor {
developer_instructions,
personality,
exclude_turns,
initial_turns_page,
persist_extended_history: _persist_extended_history,
} = params;
let include_turns = !exclude_turns;
@@ -2681,8 +2644,28 @@ impl ThreadRequestProcessor {
config_snapshot.active_permission_profile,
);
let token_usage_thread = include_turns.then(|| thread.clone());
let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() {
match build_thread_resume_initial_turns_page(
&response_history.get_rollout_items(),
thread.status.clone(),
/*has_live_running_thread*/ false,
/*active_turn*/ None,
params,
) {
Ok(page) => Some(page),
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return Ok(());
}
}
} else {
None
};
if redact_resume_payloads {
redact_thread_resume_payloads(&mut thread);
redact_thread_resume_payloads(&mut thread.turns);
if let Some(initial_turns_page) = initial_turns_page.as_mut() {
redact_thread_resume_payloads(&mut initial_turns_page.data);
}
}
let response = ThreadResumeResponse {
@@ -2698,6 +2681,7 @@ impl ThreadRequestProcessor {
sandbox,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
initial_turns_page,
};
let connection_id = request_id.connection_id;
@@ -2783,15 +2767,20 @@ impl ThreadRequestProcessor {
/*include_history*/ true,
)
.await?;
if source_thread.thread_id != existing_thread_id {
return Err(invalid_request(format!(
"cannot resume running thread {existing_thread_id} from source thread {}",
source_thread.thread_id
)));
}
Some((existing_thread_id, existing_thread, source_thread))
} else {
None
let source_thread = self
.read_stored_thread_for_resume(
&params.thread_id,
params.path.as_ref(),
/*include_history*/ true,
)
.await?;
let existing_thread_id = source_thread.thread_id;
match self.thread_manager.get_thread(existing_thread_id).await {
Ok(existing_thread) => Some((existing_thread_id, existing_thread, source_thread)),
Err(_) => None,
}
};
if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread {
@@ -2808,6 +2797,48 @@ impl ThreadRequestProcessor {
active_path.display()
)));
}
let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
if !mismatch_details.is_empty() {
let has_subscribers = !self
.thread_state_manager
.subscribed_connection_ids(existing_thread_id)
.await
.is_empty();
let loaded_status = self
.thread_watch_manager
.loaded_status_for_thread(&existing_thread_id.to_string())
.await;
let is_running =
matches!(existing_thread.agent_status().await, AgentStatus::Running);
if !has_subscribers && matches!(loaded_status, ThreadStatus::Idle) && !is_running {
// A loaded idle thread is only a cache entry. Shut it down
// before removing it so cold resume cannot duplicate a
// thread that timed out during shutdown.
match wait_for_thread_shutdown(&existing_thread).await {
ThreadShutdownResult::Complete => {
self.thread_manager.remove_thread(&existing_thread_id).await;
self.finalize_thread_teardown(existing_thread_id).await;
return Ok(false);
}
ThreadShutdownResult::SubmitFailed => {
warn!("failed to submit Shutdown to thread {existing_thread_id}");
}
ThreadShutdownResult::TimedOut => {
warn!("thread {existing_thread_id} shutdown timed out");
}
}
}
// Preserve rejoin semantics when another client can still observe
// the loaded thread or shutdown did not complete.
tracing::warn!(
"thread/resume overrides ignored for loaded thread {}: {}",
existing_thread_id,
mismatch_details.join("; ")
);
}
let redact_resume_payloads =
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
let history_items = source_thread
@@ -2837,15 +2868,6 @@ impl ThreadRequestProcessor {
)
.await?;
let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
if !mismatch_details.is_empty() {
tracing::warn!(
"thread/resume overrides ignored for running thread {}: {}",
existing_thread_id,
mismatch_details.join("; ")
);
}
let mut summary_source_thread = source_thread;
summary_source_thread.history = None;
let mut thread_summary = self.stored_thread_to_api_thread(
@@ -2884,6 +2906,7 @@ impl ThreadRequestProcessor {
emit_thread_goal_update,
thread_goal_state_db,
include_turns: !params.exclude_turns,
initial_turns_page: params.initial_turns_page.clone(),
redact_resume_payloads,
}),
);
@@ -3664,6 +3687,95 @@ fn parse_thread_turns_cursor(cursor: &str) -> Result<ThreadTurnsCursor, JSONRPCE
serde_json::from_str(cursor).map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))
}
struct ThreadTurnsPageOptions<'a> {
cursor: Option<&'a str>,
limit: Option<u32>,
sort_direction: SortDirection,
items_view: TurnItemsView,
}
fn build_thread_turns_page_response(
items: &[RolloutItem],
loaded_status: ThreadStatus,
has_live_running_thread: bool,
active_turn: Option<Turn>,
options: ThreadTurnsPageOptions<'_>,
) -> Result<ThreadTurnsListResponse, JSONRPCErrorError> {
let mut turns = reconstruct_thread_turns_for_turns_list(
items,
loaded_status,
has_live_running_thread,
active_turn,
);
apply_thread_turns_items_view(&mut turns, options.items_view);
let page = paginate_thread_turns(turns, options.cursor, options.limit, options.sort_direction)?;
Ok(ThreadTurnsListResponse {
data: page.turns,
next_cursor: page.next_cursor,
backwards_cursor: page.backwards_cursor,
})
}
pub(super) fn build_thread_resume_initial_turns_page(
items: &[RolloutItem],
loaded_status: ThreadStatus,
has_live_running_thread: bool,
active_turn: Option<Turn>,
params: &ThreadResumeInitialTurnsPageParams,
) -> Result<codex_app_server_protocol::TurnsPage, JSONRPCErrorError> {
build_thread_turns_page_response(
items,
loaded_status,
has_live_running_thread,
active_turn,
ThreadTurnsPageOptions {
cursor: None,
limit: params.limit,
sort_direction: params.sort_direction.unwrap_or(SortDirection::Desc),
items_view: params.items_view.unwrap_or(TurnItemsView::Summary),
},
)
.map(Into::into)
}
fn apply_thread_turns_items_view(turns: &mut [Turn], items_view: TurnItemsView) {
for turn in turns {
match items_view {
TurnItemsView::NotLoaded => {
turn.items.clear();
turn.items_view = TurnItemsView::NotLoaded;
}
TurnItemsView::Summary => {
let first_user_message = turn
.items
.iter()
.find(|item| matches!(item, ThreadItem::UserMessage { .. }))
.cloned();
let final_agent_message = turn
.items
.iter()
.rev()
.find(|item| matches!(item, ThreadItem::AgentMessage { .. }))
.cloned();
turn.items = match (first_user_message, final_agent_message) {
(Some(user_message), Some(agent_message))
if user_message.id() != agent_message.id() =>
{
vec![user_message, agent_message]
}
(Some(user_message), _) => vec![user_message],
(None, Some(agent_message)) => vec![agent_message],
(None, None) => Vec::new(),
};
turn.items_view = TurnItemsView::Summary;
}
TurnItemsView::Full => {
turn.items_view = TurnItemsView::Full;
}
}
}
}
fn reconstruct_thread_turns_for_turns_list(
items: &[RolloutItem],
loaded_status: ThreadStatus,
@@ -213,6 +213,7 @@ mod thread_processor_behavior_tests {
fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() {
let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage(
codex_protocol::protocol::UserMessageEvent {
client_id: None,
message: "persisted".to_string(),
images: None,
local_images: Vec::new(),
@@ -224,6 +225,7 @@ mod thread_processor_behavior_tests {
id: "live-turn".to_string(),
items: vec![ThreadItem::UserMessage {
id: "live-user-message".to_string(),
client_id: None,
content: vec![V2UserInput::Text {
text: "live".to_string(),
text_elements: Vec::new(),
@@ -610,6 +612,7 @@ mod thread_processor_behavior_tests {
Some(HashMap::from([
("model_provider".to_string(), json!("request")),
("features.plugins".to_string(), json!(true)),
("bypass_hook_trust".to_string(), json!(true)),
(
"model_providers.session".to_string(),
json!({
@@ -626,6 +629,7 @@ mod thread_processor_behavior_tests {
assert_eq!(config.model_provider_id, "session");
assert_eq!(config.model_provider, session_provider);
assert!(!config.features.enabled(Feature::Plugins));
assert!(config.bypass_hook_trust);
Ok(())
}
@@ -650,6 +654,7 @@ mod thread_processor_behavior_tests {
developer_instructions: None,
personality: None,
exclude_turns: false,
initial_turns_page: None,
persist_extended_history: false,
};
let config_snapshot = ThreadConfigSnapshot {
@@ -1,6 +1,6 @@
use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use serde_json::Value as JsonValue;
// Temporary bandaid for remote clients: thread/resume can include large MCP and
@@ -14,8 +14,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) ->
client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name))
}
pub(super) fn redact_thread_resume_payloads(thread: &mut Thread) {
for turn in &mut thread.turns {
pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
for turn in turns {
turn.items.retain_mut(|item| match item {
ThreadItem::McpToolCall {
arguments,
@@ -55,8 +55,8 @@ mod tests {
use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
use codex_utils_absolute_path::test_support::PathBufExt;
@@ -100,7 +100,7 @@ mod tests {
},
]);
redact_thread_resume_payloads(&mut thread);
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(thread.turns[0].items.len(), 2);
assert_eq!(
@@ -146,7 +146,7 @@ mod tests {
duration_ms: Some(8),
}]);
redact_thread_resume_payloads(&mut thread);
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(
thread.turns[0].items[0],
@@ -148,6 +148,7 @@ mod tests {
fn token_usage_history() -> Vec<RolloutItem> {
vec![
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "first turn".to_string(),
images: None,
local_images: Vec::new(),
@@ -164,6 +165,7 @@ mod tests {
rate_limits: None,
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
client_id: None,
message: "second turn".to_string(),
images: None,
local_images: Vec::new(),
@@ -1,4 +1,6 @@
use super::*;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
#[derive(Clone)]
pub(crate) struct TurnRequestProcessor {
@@ -30,6 +32,29 @@ fn resolve_runtime_workspace_roots(
resolved_roots
}
fn map_additional_context(
additional_context: Option<HashMap<String, AdditionalContextEntry>>,
) -> BTreeMap<String, CoreAdditionalContextEntry> {
additional_context
.unwrap_or_default()
.into_iter()
.map(|(key, entry)| {
(
key,
CoreAdditionalContextEntry {
value: entry.value,
kind: match entry.kind {
AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted,
AdditionalContextKind::Application => {
CoreAdditionalContextKind::Application
}
},
},
)
})
.collect()
}
struct ThreadSettingsBuildParams {
method: &'static str,
cwd: Option<PathBuf>,
@@ -391,6 +416,8 @@ impl TurnRequestProcessor {
.into_iter()
.map(V2UserInput::into_core)
.collect();
let client_user_message_id = params.client_user_message_id;
let additional_context = map_additional_context(params.additional_context);
let turn_has_input = !mapped_items.is_empty();
let thread_settings = self
.build_thread_settings_overrides(
@@ -419,10 +446,15 @@ impl TurnRequestProcessor {
environments: environment_selections,
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
additional_context,
thread_settings,
};
let turn_id = self
.submit_core_op(&request_id, thread.as_ref(), turn_op)
let turn_id = thread
.submit_user_input_with_client_user_message_id(
turn_op,
self.request_trace_context(&request_id).await,
client_user_message_id,
)
.await
.map_err(|err| {
let error = internal_error(format!("failed to start turn: {err}"));
@@ -746,11 +778,14 @@ impl TurnRequestProcessor {
.into_iter()
.map(V2UserInput::into_core)
.collect();
let additional_context = map_additional_context(params.additional_context);
let turn_id = thread
.steer_input(
mapped_items,
additional_context,
Some(&params.expected_turn_id),
params.client_user_message_id,
params.responsesapi_client_metadata,
)
.await
@@ -960,6 +995,7 @@ impl TurnRequestProcessor {
} else {
vec![ThreadItem::UserMessage {
id: turn_id.clone(),
client_id: None,
content: vec![V2UserInput::Text {
text: display_text.to_string(),
// Review prompt display text is synthesized; no UI element ranges to preserve.
@@ -76,10 +76,8 @@ impl WindowsSandboxRequestProcessor {
Ok(config) => {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
policy_cwd: config.cwd.to_path_buf(),
permission_profile: config.permissions.effective_permission_profile(),
workspace_roots: config.effective_workspace_roots(),
command_cwd,
env_map: std::env::vars().collect(),
codex_home: config.codex_home.to_path_buf(),
+2
View File
@@ -35,6 +35,8 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) emit_thread_goal_update: bool,
pub(crate) thread_goal_state_db: Option<StateDbHandle>,
pub(crate) include_turns: bool,
pub(crate) initial_turns_page:
Option<codex_app_server_protocol::ThreadResumeInitialTurnsPageParams>,
pub(crate) redact_resume_payloads: bool,
}
@@ -506,6 +506,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
let turn_req = mcp
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
thread_id: thread.thread.id,
client_user_message_id: None,
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -612,6 +613,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
let turn_req = mcp
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
thread_id: thread.thread.id.clone(),
client_user_message_id: None,
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -734,6 +736,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
let turn_req = mcp
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
thread_id: thread.thread.id.clone(),
client_user_message_id: None,
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -849,6 +852,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
let turn_req = mcp
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
thread_id: thread.thread.id.clone(),
client_user_message_id: None,
input: vec![codex_app_server_protocol::UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -168,11 +168,13 @@ pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> {
pub(crate) fn assert_basic_thread_initialized_event(
event: &Value,
thread_id: &str,
session_id: &str,
expected_model: &str,
initialization_mode: &str,
expected_thread_source: &str,
) {
assert_eq!(event["event_params"]["thread_id"], thread_id);
assert_eq!(event["event_params"]["session_id"], session_id);
assert_eq!(
event["event_params"]["app_server_client"]["product_client_id"],
DEFAULT_CLIENT_NAME
+68 -4
View File
@@ -316,6 +316,73 @@ connectors = false
Ok(())
}
#[tokio::test]
async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> {
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let mut app_only_tool = connector_tool("beta", "Beta App")?;
app_only_tool
.meta
.as_mut()
.expect("connector tool should include metadata")
.0
.insert("ui".to_string(), json!({ "visibility": ["app"] }));
let tools = vec![app_only_tool];
let (server_url, server_handle) =
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-app-only")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse { data, next_cursor } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, "beta");
assert!(data[0].is_accessible);
assert!(next_cursor.is_none());
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
let connectors = vec![AppInfo {
@@ -1432,10 +1499,7 @@ impl AppsServerControl {
impl ServerHandler for AppListMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
..ServerInfo::default()
}
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
@@ -110,6 +110,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -1,8 +1,16 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -60,10 +68,12 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
let client_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-start-123".to_string()),
("origin".to_string(), "gaas".to_string()),
("thread_source".to_string(), "client-supplied".to_string()),
]);
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -93,8 +103,189 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
.unwrap_or_else(|| panic!("missing x-codex-turn-metadata header"));
assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123"));
assert_eq!(metadata["origin"].as_str(), Some("gaas"));
assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied"));
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
assert!(metadata.get("session_id").is_some());
assert_eq!(
metadata["window_id"].as_str(),
request.header("x-codex-window-id").as_deref()
);
Ok(())
}
#[tokio::test]
async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
/*supports_websockets*/ false,
)?;
let source_thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadForkResponse { thread, .. } =
fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Continue".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let metadata = request
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.unwrap_or_else(|| panic!("missing x-codex-turn-metadata header"));
assert_eq!(
metadata["forked_from_thread_id"].as_str(),
Some(source_thread_id.as_str())
);
assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str()));
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
Ok(())
}
#[tokio::test]
async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let review_payload = serde_json::json!({
"findings": [],
"overall_correctness": "good",
"overall_explanation": "Done",
"overall_confidence_score": 0.5
})
.to_string();
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", &review_payload),
responses::ev_completed("resp-1"),
]),
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
/*supports_websockets*/ false,
)?;
let source_thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let ThreadForkResponse { thread, .. } =
fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?;
let review_req = mcp
.send_review_start_request(ReviewStartParams {
thread_id: thread.id.clone(),
delivery: Some(ReviewDelivery::Inline),
target: ReviewTarget::Custom {
instructions: "Review the fork".to_string(),
},
})
.await?;
let review_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(review_req)),
)
.await??;
let ReviewStartResponse {
review_thread_id, ..
} = to_response::<ReviewStartResponse>(review_resp)?;
assert_eq!(review_thread_id, thread.id);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let metadata = request
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.unwrap_or_else(|| panic!("missing x-codex-turn-metadata header"));
assert_eq!(
request.header("x-openai-subagent").as_deref(),
Some("review")
);
assert_eq!(
metadata["forked_from_thread_id"].as_str(),
Some(review_thread_id.as_str())
);
let review_request_thread_id = metadata["thread_id"]
.as_str()
.unwrap_or_else(|| panic!("missing review request thread_id"));
assert!(review_request_thread_id != review_thread_id.as_str());
assert_eq!(
request
.header("x-codex-window-id")
.as_deref()
.and_then(|window_id| window_id.split_once(':').map(|(thread_id, _)| thread_id)),
Some(review_request_thread_id)
);
assert!(metadata["turn_id"].as_str().is_some());
Ok(())
}
@@ -144,6 +335,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Run sleep".to_string(),
text_elements: Vec::new(),
@@ -174,11 +366,13 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2()
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Focus on the failure".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: Some(steer_metadata.clone()),
additional_context: None,
expected_turn_id: turn_id.clone(),
})
.await?;
@@ -268,6 +462,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -311,6 +506,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body
assert_eq!(metadata["origin"].as_str(), Some("gaas"));
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
assert!(metadata.get("session_id").is_some());
assert_eq!(
metadata["window_id"].as_str(),
request["client_metadata"]["x-codex-window-id"].as_str()
);
websocket_server.shutdown().await;
Ok(())
@@ -344,6 +543,25 @@ supports_websockets = {supports_websockets}
)
}
async fn fork_fake_rollout_thread(
mcp: &mut McpProcess,
source_thread_id: String,
) -> Result<ThreadForkResponse> {
let fork_req = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: source_thread_id,
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_req)),
)
.await??;
to_response::<ThreadForkResponse>(fork_resp)
}
fn parse_json_header(value: &str) -> serde_json::Value {
match serde_json::from_str(value) {
Ok(value) => value,
@@ -190,6 +190,58 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
let response_requests = responses_log.requests();
assert_eq!(response_requests.len(), 3);
let turn_metadata = response_requests
.iter()
.map(|request| {
request
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.unwrap_or_else(|| panic!("turn request should include turn metadata"))
})
.collect::<Vec<_>>();
for (request, metadata) in response_requests.iter().zip(&turn_metadata) {
assert_eq!(metadata["request_kind"].as_str(), Some("turn"));
assert!(
metadata["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty()),
"turn request should carry a non-empty turn id"
);
assert_eq!(
metadata["window_id"].as_str(),
request.header("x-codex-window-id").as_deref()
);
assert!(metadata.get("compaction").is_none());
}
let compact_metadata = compact_requests[0]
.header("x-codex-turn-metadata")
.as_deref()
.map(parse_json_header)
.unwrap_or_else(|| panic!("compact request should include turn metadata"));
assert_eq!(
compact_metadata["request_kind"].as_str(),
Some("compaction")
);
assert_eq!(
compact_metadata["compaction"],
serde_json::json!({
"trigger": "auto",
"reason": "context_limit",
"implementation": "responses_compact",
"phase": "pre_turn",
"strategy": "memento",
})
);
assert_eq!(
compact_metadata["turn_id"], turn_metadata[2]["turn_id"],
"pre-turn compaction should carry the current turn id"
);
assert_eq!(
compact_metadata["window_id"].as_str(),
compact_requests[0].header("x-codex-window-id").as_deref()
);
Ok(())
}
@@ -342,6 +394,7 @@ async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) -
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
@@ -407,3 +460,7 @@ async fn wait_for_context_compaction_completed(
}
}
}
fn parse_json_header(value: &str) -> serde_json::Value {
serde_json::from_str(value).unwrap_or_else(|err| panic!("turn metadata should be json: {err}"))
}
@@ -758,12 +758,15 @@ pub(super) async fn read_response_and_notification_for_method(
JSONRPCMessage::Response(candidate) if candidate.id == target_id => {
response = Some(candidate);
}
JSONRPCMessage::Notification(candidate)
if candidate.method == method && notification.is_some() =>
{
bail!(
"received duplicate notification for method `{method}` before completing paired read"
);
}
JSONRPCMessage::Notification(candidate) if candidate.method == method => {
if notification.replace(candidate).is_some() {
bail!(
"received duplicate notification for method `{method}` before completing paired read"
);
}
notification = Some(candidate);
}
_ => {}
}
@@ -226,6 +226,7 @@ async fn send_turn_start_request(stream: &mut WsClient, id: i64, thread_id: &str
id,
Some(serde_json::to_value(TurnStartParams {
thread_id: thread_id.to_string(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -89,6 +89,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -168,6 +169,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -347,6 +349,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Run the tool".to_string(),
text_elements: Vec::new(),
@@ -521,6 +524,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread_id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Run the tool".to_string(),
text_elements: Vec::new(),

Some files were not shown because too many files have changed in this diff Show More