merge main into hooks-updated-input

This commit is contained in:
Abhinav Vedmala
2026-05-05 11:16:53 -07:00
596 changed files with 50300 additions and 23751 deletions
+2
View File
@@ -6,4 +6,6 @@ ignore = [
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
"RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
]
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
+61 -7
View File
@@ -1857,8 +1857,8 @@ dependencies = [
"chrono",
"clap",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
"codex-app-server-transport",
"codex-arg0",
"codex-backend-client",
"codex-chatgpt",
@@ -1891,23 +1891,17 @@ dependencies = [
"codex-state",
"codex-thread-store",
"codex-tools",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-pty",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"core_test_support",
"flate2",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"opentelemetry",
"opentelemetry_sdk",
"owo-colors",
"pretty_assertions",
"reqwest",
"rmcp",
@@ -2005,6 +1999,45 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-app-server-transport"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"codex-api",
"codex-app-server-protocol",
"codex-config",
"codex-core",
"codex-login",
"codex-model-provider",
"codex-state",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"futures",
"gethostname",
"hmac",
"jsonwebtoken",
"owo-colors",
"pretty_assertions",
"serde",
"serde_json",
"sha2",
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tracing",
"url",
"uuid",
]
[[package]]
name = "codex-apply-patch"
version = "0.0.0"
@@ -2187,6 +2220,7 @@ dependencies = [
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest",
"rustls",
"rustls-native-certs",
@@ -2834,6 +2868,7 @@ dependencies = [
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"futures",
"pretty_assertions",
"regex",
@@ -2842,6 +2877,8 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"tracing",
"uuid",
]
[[package]]
@@ -3004,6 +3041,23 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"pretty_assertions",
"rmcp",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "codex-memories-read"
version = "0.0.0"
+9
View File
@@ -8,6 +8,7 @@ members = [
"ansi-escape",
"async-utils",
"app-server",
"app-server-transport",
"app-server-client",
"app-server-protocol",
"app-server-test-client",
@@ -51,6 +52,7 @@ members = [
"login",
"codex-mcp",
"mcp-server",
"memories/mcp",
"memories/read",
"memories/write",
"model-provider-info",
@@ -127,6 +129,7 @@ codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-aws-auth = { path = "aws-auth" }
codex-app-server = { path = "app-server" }
codex-app-server-transport = { path = "app-server-transport" }
codex-app-server-client = { path = "app-server-client" }
codex-app-server-protocol = { path = "app-server-protocol" }
codex-app-server-test-client = { path = "app-server-test-client" }
@@ -166,6 +169,7 @@ codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-memories-mcp = { path = "memories/mcp" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
@@ -320,6 +324,10 @@ quick-xml = "0.38.4"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
rcgen = { version = "0.14.7", default-features = false, features = [
"aws_lc_rs",
"pem",
] }
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
@@ -455,6 +463,7 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-memories-mcp",
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
@@ -303,10 +303,10 @@ fn sample_turn_completed_notification(
})
}
fn sample_turn_resolved_config(turn_id: &str) -> TurnResolvedConfigFact {
fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedConfigFact {
TurnResolvedConfigFact {
turn_id: turn_id.to_string(),
thread_id: "thread-2".to_string(),
thread_id: thread_id.to_string(),
num_input_images: 1,
submission_type: None,
ephemeral: false,
@@ -419,6 +419,38 @@ async fn ingest_rejected_turn_steer(
/*include_started*/ false, /*include_token_usage*/ false,
)
.await;
reducer
.ingest(
AnalyticsFact::Initialize {
connection_id: 8,
params: InitializeParams {
client_info: ClientInfo {
name: "codex-web".to_string(),
title: None,
version: "1.0.0".to_string(),
},
capabilities: None,
},
product_client_id: "codex-web".to_string(),
runtime: sample_runtime_metadata(),
rpc_transport: AppServerRpcTransport::Stdio,
},
out,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 8,
request_id: RequestId::Integer(6),
response: Box::new(sample_thread_resume_response(
"thread-2", /*ephemeral*/ false, "gpt-5",
)),
},
out,
)
.await;
out.clear();
reducer
.ingest(
AnalyticsFact::ClientRequest {
@@ -519,7 +551,7 @@ async fn ingest_turn_prerequisites(
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new(
sample_turn_resolved_config("turn-2"),
sample_turn_resolved_config("thread-2", "turn-2"),
))),
out,
)
@@ -1436,6 +1468,110 @@ async fn subagent_thread_started_publishes_without_initialize() {
assert_eq!(payload[0]["event_params"]["subagent_source"], "review");
}
#[tokio::test]
async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
let parent_thread_id =
codex_protocol::ThreadId::from_string("44444444-4444-4444-4444-444444444444")
.expect("valid parent thread id");
let parent_thread_id_string = parent_thread_id.to_string();
reducer
.ingest(
AnalyticsFact::Initialize {
connection_id: 7,
params: InitializeParams {
client_info: ClientInfo {
name: "parent-client".to_string(),
title: None,
version: "1.0.0".to_string(),
},
capabilities: None,
},
product_client_id: "parent-client".to_string(),
runtime: sample_runtime_metadata(),
rpc_transport: AppServerRpcTransport::Stdio,
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 7,
request_id: RequestId::Integer(1),
response: Box::new(sample_thread_start_response(
&parent_thread_id_string,
/*ephemeral*/ false,
"gpt-5",
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted(
SubAgentThreadStartedInput {
thread_id: "thread-review".to_string(),
parent_thread_id: None,
product_client_id: "parent-client".to_string(),
client_name: "parent-client".to_string(),
client_version: "1.0.0".to_string(),
model: "gpt-5".to_string(),
ephemeral: false,
subagent_source: SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
},
created_at: 130,
},
)),
&mut events,
)
.await;
events.clear();
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(Box::new(
CodexCompactionEvent {
thread_id: "thread-review".to_string(),
turn_id: "turn-compact".to_string(),
trigger: CompactionTrigger::Manual,
reason: CompactionReason::UserRequested,
implementation: CompactionImplementation::Responses,
phase: CompactionPhase::StandaloneTurn,
strategy: CompactionStrategy::Memento,
status: CompactionStatus::Completed,
error: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 64_000,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
},
))),
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(
payload[0]["event_params"]["app_server_client"]["product_client_id"],
"parent-client"
);
assert_eq!(
payload[0]["event_params"]["parent_thread_id"],
"44444444-4444-4444-4444-444444444444"
);
}
#[test]
fn plugin_used_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
@@ -1496,6 +1632,25 @@ fn plugin_management_event_serializes_expected_shape() {
);
}
#[test]
fn plugin_management_event_can_use_remote_plugin_id_override() {
let mut plugin = sample_plugin_metadata();
plugin.remote_plugin_id = Some("plugins~Plugin_remote".to_string());
let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest {
event_type: "codex_plugin_installed",
event_params: codex_plugin_metadata(plugin),
});
let payload = serde_json::to_value(&event).expect("serialize plugin installed event");
assert_eq!(
payload["event_params"]["plugin_id"],
"plugins~Plugin_remote"
);
assert_eq!(payload["event_params"]["plugin_name"], "sample");
assert_eq!(payload["event_params"]["marketplace_name"], "test");
}
#[test]
fn hook_run_event_serializes_expected_shape() {
let tracking = TrackEventsContext {
@@ -1661,6 +1816,7 @@ async fn reducer_ingests_skill_invoked_fact() {
skill_name: "doc".to_string(),
skill_scope: codex_protocol::protocol::SkillScope::User,
skill_path,
plugin_id: None,
invocation_type: InvocationType::Explicit,
}],
})),
@@ -1678,8 +1834,10 @@ async fn reducer_ingests_skill_invoked_fact() {
"event_params": {
"product_client_id": originator().value,
"skill_scope": "user",
"plugin_id": null,
"repo_url": null,
"thread_id": "thread-1",
"turn_id": "turn-1",
"invoke_type": "explicit",
"model_slug": "gpt-5"
}
@@ -1687,6 +1845,41 @@ async fn reducer_ingests_skill_invoked_fact() {
);
}
#[tokio::test]
async fn reducer_includes_plugin_id_for_plugin_skill_invocations() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
let tracking = TrackEventsContext {
model_slug: "gpt-5".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
};
let skill_path =
PathBuf::from("/Users/abc/.codex/plugins/cache/test/sample/skills/doc/SKILL.md");
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput {
tracking,
invocations: vec![SkillInvocation {
skill_name: "sample:doc".to_string(),
skill_scope: codex_protocol::protocol::SkillScope::User,
skill_path,
plugin_id: Some("sample@test".to_string()),
invocation_type: InvocationType::Explicit,
}],
})),
&mut events,
)
.await;
let payload = serde_json::to_value(&events).expect("serialize events");
assert_eq!(
payload[0]["event_params"]["plugin_id"],
json!("sample@test")
);
}
#[tokio::test]
async fn reducer_ingests_hook_run_fact() {
let mut reducer = AnalyticsReducer::default();
@@ -2130,7 +2323,7 @@ async fn turn_start_error_response_discards_pending_start_request() {
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new(
sample_turn_resolved_config("turn-2"),
sample_turn_resolved_config("thread-2", "turn-2"),
))),
&mut out,
)
@@ -2482,6 +2675,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() {
fn sample_plugin_metadata() -> PluginTelemetryMetadata {
PluginTelemetryMetadata {
plugin_id: PluginId::parse("sample@test").expect("valid plugin id"),
remote_plugin_id: None,
capability_summary: Some(PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
+11 -4
View File
@@ -80,8 +80,10 @@ pub(crate) struct SkillInvocationEventRequest {
pub(crate) struct SkillInvocationEventParams {
pub(crate) product_client_id: Option<String>,
pub(crate) skill_scope: Option<String>,
pub(crate) plugin_id: Option<String>,
pub(crate) repo_url: Option<String>,
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) invoke_type: Option<InvocationType>,
pub(crate) model_slug: Option<String>,
}
@@ -587,11 +589,16 @@ pub(crate) fn codex_app_metadata(
}
pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata {
let capability_summary = plugin.capability_summary;
let PluginTelemetryMetadata {
plugin_id,
remote_plugin_id,
capability_summary,
} = plugin;
let event_plugin_id = remote_plugin_id.unwrap_or_else(|| plugin_id.as_key());
CodexPluginMetadata {
plugin_id: Some(plugin.plugin_id.as_key()),
plugin_name: Some(plugin.plugin_id.plugin_name),
marketplace_name: Some(plugin.plugin_id.marketplace_name),
plugin_id: Some(event_plugin_id),
plugin_name: Some(plugin_id.plugin_name),
marketplace_name: Some(plugin_id.marketplace_name),
has_skills: capability_summary
.as_ref()
.map(|summary| summary.has_skills),
+1
View File
@@ -173,6 +173,7 @@ pub struct SkillInvocation {
pub skill_name: String,
pub skill_scope: SkillScope,
pub skill_path: PathBuf,
pub plugin_id: Option<String>,
pub invocation_type: InvocationType,
}
+185 -77
View File
@@ -74,8 +74,7 @@ pub(crate) struct AnalyticsReducer {
requests: HashMap<(u64, RequestId), RequestState>,
turns: HashMap<String, TurnState>,
connections: HashMap<u64, ConnectionState>,
thread_connections: HashMap<String, u64>,
thread_metadata: HashMap<String, ThreadMetadataState>,
threads: HashMap<String, ThreadAnalyticsState>,
}
struct ConnectionState {
@@ -83,6 +82,69 @@ struct ConnectionState {
runtime: CodexRuntimeMetadata,
}
#[derive(Default)]
struct ThreadAnalyticsState {
connection_id: Option<u64>,
metadata: Option<ThreadMetadataState>,
}
#[derive(Clone, Copy)]
struct AnalyticsDropSite<'a> {
event_name: &'static str,
thread_id: &'a str,
turn_id: Option<&'a str>,
review_id: Option<&'a str>,
item_id: Option<&'a str>,
}
impl<'a> AnalyticsDropSite<'a> {
fn guardian(input: &'a GuardianReviewEventParams) -> Self {
Self {
event_name: "guardian",
thread_id: &input.thread_id,
turn_id: Some(&input.turn_id),
review_id: Some(&input.review_id),
item_id: None,
}
}
fn compaction(input: &'a CodexCompactionEvent) -> Self {
Self {
event_name: "compaction",
thread_id: &input.thread_id,
turn_id: Some(&input.turn_id),
review_id: None,
item_id: None,
}
}
fn turn_steer(thread_id: &'a str) -> Self {
Self {
event_name: "turn steer",
thread_id,
turn_id: None,
review_id: None,
item_id: None,
}
}
fn turn(thread_id: &'a str, turn_id: &'a str) -> Self {
Self {
event_name: "turn",
thread_id,
turn_id: Some(turn_id),
review_id: None,
item_id: None,
}
}
}
enum MissingAnalyticsContext {
ThreadConnection,
Connection { connection_id: u64 },
ThreadMetadata,
}
#[derive(Clone)]
struct ThreadMetadataState {
thread_source: Option<&'static str>,
@@ -274,6 +336,26 @@ impl AnalyticsReducer {
input: SubAgentThreadStartedInput,
out: &mut Vec<TrackEventRequest>,
) {
let parent_thread_id = input
.parent_thread_id
.clone()
.or_else(|| subagent_parent_thread_id(&input.subagent_source));
let parent_connection_id = parent_thread_id
.as_ref()
.and_then(|parent_thread_id| self.threads.get(parent_thread_id))
.and_then(|thread| thread.connection_id);
let thread_state = self.threads.entry(input.thread_id.clone()).or_default();
thread_state
.metadata
.get_or_insert_with(|| ThreadMetadataState {
thread_source: Some("subagent"),
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id,
});
if thread_state.connection_id.is_none() {
thread_state.connection_id = parent_connection_id;
}
out.push(TrackEventRequest::ThreadInitialized(
subagent_thread_started_event_request(input),
));
@@ -284,23 +366,9 @@ impl AnalyticsReducer {
input: GuardianReviewEventParams,
out: &mut Vec<TrackEventRequest>,
) {
let Some(connection_id) = self.thread_connections.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
review_id = %input.review_id,
"dropping guardian analytics event: missing thread connection metadata"
);
return;
};
let Some(connection_state) = self.connections.get(connection_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
review_id = %input.review_id,
connection_id,
"dropping guardian analytics event: missing connection metadata"
);
let Some(connection_state) =
self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input))
else {
return;
};
out.push(TrackEventRequest::GuardianReview(Box::new(
@@ -428,11 +496,13 @@ impl AnalyticsReducer {
skill_name: invocation.skill_name.clone(),
event_params: SkillInvocationEventParams {
thread_id: Some(tracking.thread_id.clone()),
turn_id: Some(tracking.turn_id.clone()),
invoke_type: Some(invocation.invocation_type),
model_slug: Some(tracking.model_slug.clone()),
product_client_id: Some(originator().value),
repo_url,
skill_scope: Some(skill_scope.to_string()),
plugin_id: invocation.plugin_id,
},
},
));
@@ -686,10 +756,13 @@ impl AnalyticsReducer {
};
let thread_metadata =
ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode);
self.thread_connections
.insert(thread_id.clone(), connection_id);
self.thread_metadata
.insert(thread_id.clone(), thread_metadata.clone());
self.threads.insert(
thread_id.clone(),
ThreadAnalyticsState {
connection_id: Some(connection_id),
metadata: Some(thread_metadata.clone()),
},
);
out.push(TrackEventRequest::ThreadInitialized(
ThreadInitializedEvent {
event_type: "codex_thread_initialized",
@@ -710,29 +783,9 @@ impl AnalyticsReducer {
}
fn ingest_compaction(&mut self, input: CodexCompactionEvent, out: &mut Vec<TrackEventRequest>) {
let Some(connection_id) = self.thread_connections.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping compaction analytics event: missing thread connection metadata"
);
return;
};
let Some(connection_state) = self.connections.get(connection_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
connection_id,
"dropping compaction analytics event: missing connection metadata"
);
return;
};
let Some(thread_metadata) = self.thread_metadata.get(&input.thread_id) else {
tracing::warn!(
thread_id = %input.thread_id,
turn_id = %input.turn_id,
"dropping compaction analytics event: missing thread lifecycle metadata"
);
let Some((connection_state, thread_metadata)) =
self.thread_context_or_warn(AnalyticsDropSite::compaction(&input))
else {
return;
};
out.push(TrackEventRequest::Compaction(Box::new(
@@ -787,11 +840,13 @@ impl AnalyticsReducer {
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
let Some(thread_metadata) = self.thread_metadata.get(&pending_request.thread_id) else {
tracing::warn!(
thread_id = %pending_request.thread_id,
"dropping turn steer analytics event: missing thread lifecycle metadata"
);
let drop_site = AnalyticsDropSite::turn_steer(&pending_request.thread_id);
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
out.push(TrackEventRequest::TurnSteer(CodexTurnSteerEventRequest {
@@ -824,42 +879,34 @@ impl AnalyticsReducer {
{
return;
}
let connection_metadata = turn_state
.connection_id
.and_then(|connection_id| self.connections.get(&connection_id))
.map(|connection_state| {
(
connection_state.app_server_client.clone(),
connection_state.runtime.clone(),
)
});
let Some((app_server_client, runtime)) = connection_metadata else {
if let Some(connection_id) = turn_state.connection_id {
tracing::warn!(
turn_id,
connection_id,
"dropping turn analytics event: missing connection metadata"
);
}
return;
};
let Some(thread_id) = turn_state.thread_id.as_ref() else {
return;
};
let Some(thread_metadata) = self.thread_metadata.get(thread_id) else {
tracing::warn!(
thread_id,
turn_id,
"dropping turn analytics event: missing thread lifecycle metadata"
let Some(connection_id) = turn_state.connection_id else {
return;
};
let Some(connection_state) = self.connections.get(&connection_id) else {
warn_missing_analytics_context(
&AnalyticsDropSite::turn(thread_id, turn_id),
MissingAnalyticsContext::Connection { connection_id },
);
return;
};
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return;
};
out.push(TrackEventRequest::TurnEvent(Box::new(
CodexTurnEventRequest {
event_type: "codex_turn_event",
event_params: codex_turn_event_params(
app_server_client,
runtime,
connection_state.app_server_client.clone(),
connection_state.runtime.clone(),
turn_id.to_string(),
turn_state,
thread_metadata,
@@ -868,6 +915,67 @@ impl AnalyticsReducer {
)));
self.turns.remove(turn_id);
}
fn thread_connection_or_warn(
&self,
drop_site: AnalyticsDropSite<'_>,
) -> Option<&ConnectionState> {
let Some(thread_state) = self.threads.get(drop_site.thread_id) else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
return None;
};
let Some(connection_id) = thread_state.connection_id else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
return None;
};
let Some(connection_state) = self.connections.get(&connection_id) else {
warn_missing_analytics_context(
&drop_site,
MissingAnalyticsContext::Connection { connection_id },
);
return None;
};
Some(connection_state)
}
fn thread_context_or_warn(
&self,
drop_site: AnalyticsDropSite<'_>,
) -> Option<(&ConnectionState, &ThreadMetadataState)> {
let connection_state = self.thread_connection_or_warn(drop_site)?;
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.metadata.as_ref())
else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata);
return None;
};
Some((connection_state, thread_metadata))
}
}
fn warn_missing_analytics_context(
drop_site: &AnalyticsDropSite<'_>,
missing: MissingAnalyticsContext,
) {
let (missing_context, connection_id) = match missing {
MissingAnalyticsContext::ThreadConnection => ("thread_connection", None),
MissingAnalyticsContext::Connection { connection_id } => {
("connection", Some(connection_id))
}
MissingAnalyticsContext::ThreadMetadata => ("thread_metadata", None),
};
tracing::warn!(
thread_id = %drop_site.thread_id,
turn_id = ?drop_site.turn_id,
review_id = ?drop_site.review_id,
item_id = ?drop_site.item_id,
missing_context,
connection_id,
"dropping {} analytics event: missing analytics context",
drop_site.event_name
);
}
fn codex_turn_event_params(
+23 -2
View File
@@ -29,6 +29,7 @@ pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY;
pub use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server::in_process::LogDbLayer;
pub use codex_app_server::in_process::StateDbHandle;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
@@ -300,7 +301,15 @@ impl fmt::Display for TypedRequestError {
write!(f, "{method} transport error: {source}")
}
Self::Server { method, source } => {
write!(f, "{method} failed: {}", source.message)
write!(
f,
"{method} failed: {} (code {})",
source.message, source.code
)?;
if let Some(data) = source.data.as_ref() {
write!(f, ", data: {data}")?;
}
Ok(())
}
Self::Deserialize { method, source } => {
write!(f, "{method} response decode error: {source}")
@@ -335,6 +344,8 @@ pub struct InProcessClientStartArgs {
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Process-wide SQLite state handle shared with the embedded app-server.
pub state_db: Option<StateDbHandle>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -396,6 +407,7 @@ impl InProcessClientStartArgs {
thread_config_loader,
feedback: self.feedback,
log_db: self.log_db,
state_db: self.state_db,
environment_manager: self.environment_manager,
config_warnings: self.config_warnings,
session_source: self.session_source,
@@ -975,6 +987,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source,
@@ -1126,6 +1139,7 @@ mod tests {
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
completed_at_ms: 0,
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "item".to_string(),
text: text.to_string(),
@@ -1915,11 +1929,15 @@ mod tests {
method: "thread/read".to_string(),
source: JSONRPCErrorError {
code: -32603,
data: None,
data: Some(serde_json::json!({"detail": "config lock mismatch"})),
message: "internal".to_string(),
},
};
assert_eq!(std::error::Error::source(&server).is_some(), false);
assert_eq!(
server.to_string(),
"thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}"
);
let deserialize = TypedRequestError::Deserialize {
method: "thread/start".to_string(),
@@ -1995,6 +2013,7 @@ mod tests {
codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
completed_at_ms: 0,
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "item".to_string(),
text: "hello".to_string(),
@@ -2045,6 +2064,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: environment_manager.clone(),
config_warnings: Vec::new(),
session_source: SessionSource::Exec,
@@ -2084,6 +2104,7 @@ mod tests {
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Exec,
+110 -70
View File
@@ -2217,6 +2217,25 @@
],
"type": "object"
},
"PluginSkillReadParams": {
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"type": "object"
},
"PluginUninstallParams": {
"properties": {
"pluginId": {
@@ -2246,6 +2265,28 @@
],
"type": "object"
},
"ProcessTerminalSize": {
"description": "PTY size in character cells for `process/spawn` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"RealtimeOutputModality": {
"enum": [
"text",
@@ -2831,6 +2872,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -3453,10 +3516,6 @@
"ephemeral": {
"type": "boolean"
},
"excludeTurns": {
"description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -3858,10 +3917,6 @@
"null"
]
},
"excludeTurns": {
"description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the resumed thread, if any.",
"type": [
@@ -4121,44 +4176,6 @@
],
"type": "string"
},
"ThreadTurnsListParams": {
"properties": {
"cursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
"type": [
"string",
"null"
]
},
"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."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadUnarchiveParams": {
"properties": {
"threadId": {
@@ -4889,30 +4906,6 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/turns/list"
],
"title": "Thread/turns/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadTurnsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turns/listRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
@@ -5106,6 +5099,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5897,6 +5914,29 @@
"title": "WindowsSandbox/setupStartRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"windowsSandbox/readiness"
],
"title": "WindowsSandbox/readinessRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "WindowsSandbox/readinessRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1932,6 +1932,11 @@
},
"ItemCompletedNotification": {
"properties": {
"completedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle completed.",
"format": "int64",
"type": "integer"
},
"item": {
"$ref": "#/definitions/ThreadItem"
},
@@ -1943,6 +1948,7 @@
}
},
"required": [
"completedAtMs",
"item",
"threadId",
"turnId"
@@ -2030,6 +2036,11 @@
"item": {
"$ref": "#/definitions/ThreadItem"
},
"startedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle started.",
"format": "int64",
"type": "integer"
},
"threadId": {
"type": "string"
},
@@ -2039,6 +2050,7 @@
},
"required": [
"item",
"startedAtMs",
"threadId",
"turnId"
],
@@ -2403,6 +2415,96 @@
],
"type": "string"
},
"ProcessExitedNotification": {
"description": "Final process exit notification for `process/spawn`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
"type": "string"
},
"stderrCapReached": {
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
"type": "boolean"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
"type": "string"
},
"stdoutCapReached": {
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
"type": "boolean"
}
},
"required": [
"exitCode",
"processHandle",
"stderr",
"stderrCapReached",
"stdout",
"stdoutCapReached"
],
"type": "object"
},
"ProcessOutputDeltaNotification": {
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
"properties": {
"capReached": {
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/ProcessOutputStream"
}
],
"description": "Output stream this chunk belongs to."
}
},
"required": [
"capReached",
"deltaBase64",
"processHandle",
"stream"
],
"type": "object"
},
"ProcessOutputStream": {
"description": "Stream label for `process/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"RateLimitReachedType": {
"enum": [
"rate_limit_reached",
@@ -5151,6 +5253,48 @@
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/outputDelta"
],
"title": "Process/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProcessOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/outputDeltaNotification",
"type": "object"
},
{
"description": "Final exit notification for a `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/exited"
],
"title": "Process/exitedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProcessExitedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/exitedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -5896,4 +6040,4 @@
}
],
"title": "ServerNotification"
}
}
@@ -569,30 +569,6 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/turns/list"
],
"title": "Thread/turns/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadTurnsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turns/listRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
@@ -786,6 +762,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1577,6 +1577,29 @@
"title": "WindowsSandbox/setupStartRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"windowsSandbox/readiness"
],
"title": "WindowsSandbox/readinessRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "WindowsSandbox/readinessRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4248,6 +4271,48 @@
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/outputDelta"
],
"title": "Process/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ProcessOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/outputDeltaNotification",
"type": "object"
},
{
"description": "Final exit notification for a `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/exited"
],
"title": "Process/exitedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ProcessExitedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/exitedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -10109,6 +10174,11 @@
"ItemCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"completedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle completed.",
"format": "int64",
"type": "integer"
},
"item": {
"$ref": "#/definitions/v2/ThreadItem"
},
@@ -10120,6 +10190,7 @@
}
},
"required": [
"completedAtMs",
"item",
"threadId",
"turnId"
@@ -10213,6 +10284,11 @@
"item": {
"$ref": "#/definitions/v2/ThreadItem"
},
"startedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle started.",
"format": "int64",
"type": "integer"
},
"threadId": {
"type": "string"
},
@@ -10222,6 +10298,7 @@
},
"required": [
"item",
"startedAtMs",
"threadId",
"turnId"
],
@@ -11212,6 +11289,7 @@
"properties": {
"additionalSpeedTiers": {
"default": [],
"description": "Deprecated: use `serviceTiers` instead.",
"items": {
"type": "string"
},
@@ -11258,6 +11336,13 @@
"model": {
"type": "string"
},
"serviceTiers": {
"default": [],
"items": {
"$ref": "#/definitions/v2/ModelServiceTier"
},
"type": "array"
},
"supportedReasoningEfforts": {
"items": {
"$ref": "#/definitions/v2/ReasoningEffortOption"
@@ -11422,6 +11507,25 @@
"title": "ModelReroutedNotification",
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
@@ -11956,6 +12060,23 @@
],
"type": "string"
},
"PluginAvailability": {
"oneOf": [
{
"enum": [
"DISABLED_BY_ADMIN"
],
"type": "string"
},
{
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
"enum": [
"AVAILABLE"
],
"type": "string"
}
]
},
"PluginDetail": {
"properties": {
"apps": {
@@ -12341,6 +12462,31 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/v2/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
@@ -12351,7 +12497,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/PluginSummary"
"$ref": "#/definitions/v2/PluginShareListItem"
},
"type": "array"
}
@@ -12398,6 +12544,40 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
},
"PluginSkillReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -12482,6 +12662,15 @@
"authPolicy": {
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
"availability": {
"allOf": [
{
"$ref": "#/definitions/v2/PluginAvailability"
}
],
"default": "AVAILABLE",
"description": "Availability state for installing and using the plugin."
},
"enabled": {
"type": "boolean"
},
@@ -12558,6 +12747,122 @@
],
"type": "object"
},
"ProcessExitedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final process exit notification for `process/spawn`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
"type": "string"
},
"stderrCapReached": {
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
"type": "boolean"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
"type": "string"
},
"stdoutCapReached": {
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
"type": "boolean"
}
},
"required": [
"exitCode",
"processHandle",
"stderr",
"stderrCapReached",
"stdout",
"stdoutCapReached"
],
"title": "ProcessExitedNotification",
"type": "object"
},
"ProcessOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
"properties": {
"capReached": {
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/v2/ProcessOutputStream"
}
],
"description": "Output stream this chunk belongs to."
}
},
"required": [
"capReached",
"deltaBase64",
"processHandle",
"stream"
],
"title": "ProcessOutputDeltaNotification",
"type": "object"
},
"ProcessOutputStream": {
"description": "Stream label for `process/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"ProcessTerminalSize": {
"description": "PTY size in character cells for `process/spawn` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -13706,6 +14011,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -15053,10 +15380,6 @@
"ephemeral": {
"type": "boolean"
},
"excludeTurns": {
"description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -16554,10 +16877,6 @@
"null"
]
},
"excludeTurns": {
"description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the resumed thread, if any.",
"type": [
@@ -17152,76 +17471,6 @@
"title": "ThreadTokenUsageUpdatedNotification",
"type": "object"
},
"ThreadTurnsListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
"type": [
"string",
"null"
]
},
"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."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnsListParams",
"type": "object"
},
"ThreadTurnsListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"backwardsCursor": {
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.",
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/v2/Turn"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.",
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadTurnsListResponse",
"type": "object"
},
"ThreadUnarchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -18129,6 +18378,27 @@
},
"type": "object"
},
"WindowsSandboxReadiness": {
"enum": [
"ready",
"notConfigured",
"updateRequired"
],
"type": "string"
},
"WindowsSandboxReadinessResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"status": {
"$ref": "#/definitions/v2/WindowsSandboxReadiness"
}
},
"required": [
"status"
],
"title": "WindowsSandboxReadinessResponse",
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -18232,4 +18502,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -1328,30 +1328,6 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/turns/list"
],
"title": "Thread/turns/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadTurnsListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turns/listRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
@@ -1545,6 +1521,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2336,6 +2336,29 @@
"title": "WindowsSandbox/setupStartRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"windowsSandbox/readiness"
],
"title": "WindowsSandbox/readinessRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "WindowsSandbox/readinessRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -6762,6 +6785,11 @@
"ItemCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"completedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle completed.",
"format": "int64",
"type": "integer"
},
"item": {
"$ref": "#/definitions/ThreadItem"
},
@@ -6773,6 +6801,7 @@
}
},
"required": [
"completedAtMs",
"item",
"threadId",
"turnId"
@@ -6866,6 +6895,11 @@
"item": {
"$ref": "#/definitions/ThreadItem"
},
"startedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle started.",
"format": "int64",
"type": "integer"
},
"threadId": {
"type": "string"
},
@@ -6875,6 +6909,7 @@
},
"required": [
"item",
"startedAtMs",
"threadId",
"turnId"
],
@@ -7865,6 +7900,7 @@
"properties": {
"additionalSpeedTiers": {
"default": [],
"description": "Deprecated: use `serviceTiers` instead.",
"items": {
"type": "string"
},
@@ -7911,6 +7947,13 @@
"model": {
"type": "string"
},
"serviceTiers": {
"default": [],
"items": {
"$ref": "#/definitions/ModelServiceTier"
},
"type": "array"
},
"supportedReasoningEfforts": {
"items": {
"$ref": "#/definitions/ReasoningEffortOption"
@@ -8075,6 +8118,25 @@
"title": "ModelReroutedNotification",
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
@@ -8609,6 +8671,23 @@
],
"type": "string"
},
"PluginAvailability": {
"oneOf": [
{
"enum": [
"DISABLED_BY_ADMIN"
],
"type": "string"
},
{
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
"enum": [
"AVAILABLE"
],
"type": "string"
}
]
},
"PluginDetail": {
"properties": {
"apps": {
@@ -8994,6 +9073,31 @@
"title": "PluginShareDeleteResponse",
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginShareListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PluginShareListParams",
@@ -9004,7 +9108,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
"$ref": "#/definitions/PluginShareListItem"
},
"type": "array"
}
@@ -9051,6 +9155,40 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
},
"PluginSkillReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -9135,6 +9273,15 @@
"authPolicy": {
"$ref": "#/definitions/PluginAuthPolicy"
},
"availability": {
"allOf": [
{
"$ref": "#/definitions/PluginAvailability"
}
],
"default": "AVAILABLE",
"description": "Availability state for installing and using the plugin."
},
"enabled": {
"type": "boolean"
},
@@ -9211,6 +9358,122 @@
],
"type": "object"
},
"ProcessExitedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final process exit notification for `process/spawn`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
"type": "string"
},
"stderrCapReached": {
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
"type": "boolean"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
"type": "string"
},
"stdoutCapReached": {
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
"type": "boolean"
}
},
"required": [
"exitCode",
"processHandle",
"stderr",
"stderrCapReached",
"stdout",
"stdoutCapReached"
],
"title": "ProcessExitedNotification",
"type": "object"
},
"ProcessOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
"properties": {
"capReached": {
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/ProcessOutputStream"
}
],
"description": "Output stream this chunk belongs to."
}
},
"required": [
"capReached",
"deltaBase64",
"processHandle",
"stream"
],
"title": "ProcessOutputDeltaNotification",
"type": "object"
},
"ProcessOutputStream": {
"description": "Stream label for `process/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
},
"ProcessTerminalSize": {
"description": "PTY size in character cells for `process/spawn` PTY sessions.",
"properties": {
"cols": {
"description": "Terminal width in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"rows": {
"description": "Terminal height in character cells.",
"format": "uint16",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"cols",
"rows"
],
"type": "object"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -10359,6 +10622,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -11267,6 +11552,48 @@
"title": "Command/exec/outputDeltaNotification",
"type": "object"
},
{
"description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/outputDelta"
],
"title": "Process/outputDeltaNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProcessOutputDeltaNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/outputDeltaNotification",
"type": "object"
},
{
"description": "Final exit notification for a `process/spawn` session.",
"properties": {
"method": {
"enum": [
"process/exited"
],
"title": "Process/exitedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProcessExitedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Process/exitedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -12939,10 +13266,6 @@
"ephemeral": {
"type": "boolean"
},
"excludeTurns": {
"description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -14440,10 +14763,6 @@
"null"
]
},
"excludeTurns": {
"description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the resumed thread, if any.",
"type": [
@@ -15038,76 +15357,6 @@
"title": "ThreadTokenUsageUpdatedNotification",
"type": "object"
},
"ThreadTurnsListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
"type": [
"string",
"null"
]
},
"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."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnsListParams",
"type": "object"
},
"ThreadTurnsListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"backwardsCursor": {
"description": "Opaque cursor to pass as `cursor` when reversing `sortDirection`. This is only populated when the page contains at least one turn. Use it with the opposite `sortDirection` to include the anchor turn again and catch updates to that turn.",
"type": [
"string",
"null"
]
},
"data": {
"items": {
"$ref": "#/definitions/Turn"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn. if None, there are no more turns to return.",
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadTurnsListResponse",
"type": "object"
},
"ThreadUnarchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -16015,6 +16264,27 @@
},
"type": "object"
},
"WindowsSandboxReadiness": {
"enum": [
"ready",
"notConfigured",
"updateRequired"
],
"type": "string"
},
"WindowsSandboxReadinessResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"status": {
"$ref": "#/definitions/WindowsSandboxReadiness"
}
},
"required": [
"status"
],
"title": "WindowsSandboxReadinessResponse",
"type": "object"
},
"WindowsSandboxSetupCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -16117,4 +16387,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -1370,6 +1370,11 @@
}
},
"properties": {
"completedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle completed.",
"format": "int64",
"type": "integer"
},
"item": {
"$ref": "#/definitions/ThreadItem"
},
@@ -1381,10 +1386,11 @@
}
},
"required": [
"completedAtMs",
"item",
"threadId",
"turnId"
],
"title": "ItemCompletedNotification",
"type": "object"
}
}
@@ -1373,6 +1373,11 @@
"item": {
"$ref": "#/definitions/ThreadItem"
},
"startedAtMs": {
"description": "Unix timestamp (in milliseconds) when this item lifecycle started.",
"format": "int64",
"type": "integer"
},
"threadId": {
"type": "string"
},
@@ -1382,9 +1387,10 @@
},
"required": [
"item",
"startedAtMs",
"threadId",
"turnId"
],
"title": "ItemStartedNotification",
"type": "object"
}
}
@@ -24,6 +24,7 @@
"properties": {
"additionalSpeedTiers": {
"default": [],
"description": "Deprecated: use `serviceTiers` instead.",
"items": {
"type": "string"
},
@@ -70,6 +71,13 @@
"model": {
"type": "string"
},
"serviceTiers": {
"default": [],
"items": {
"$ref": "#/definitions/ModelServiceTier"
},
"type": "array"
},
"supportedReasoningEfforts": {
"items": {
"$ref": "#/definitions/ReasoningEffortOption"
@@ -120,6 +128,25 @@
],
"type": "object"
},
"ModelServiceTier": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
@@ -38,6 +38,23 @@
],
"type": "string"
},
"PluginAvailability": {
"oneOf": [
{
"enum": [
"DISABLED_BY_ADMIN"
],
"type": "string"
},
{
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
"enum": [
"AVAILABLE"
],
"type": "string"
}
]
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
@@ -299,6 +316,15 @@
"authPolicy": {
"$ref": "#/definitions/PluginAuthPolicy"
},
"availability": {
"allOf": [
{
"$ref": "#/definitions/PluginAvailability"
}
],
"default": "AVAILABLE",
"description": "Availability state for installing and using the plugin."
},
"enabled": {
"type": "boolean"
},
@@ -44,6 +44,23 @@
],
"type": "string"
},
"PluginAvailability": {
"oneOf": [
{
"enum": [
"DISABLED_BY_ADMIN"
],
"type": "string"
},
{
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
"enum": [
"AVAILABLE"
],
"type": "string"
}
]
},
"PluginDetail": {
"properties": {
"apps": {
@@ -318,6 +335,15 @@
"authPolicy": {
"$ref": "#/definitions/PluginAuthPolicy"
},
"availability": {
"allOf": [
{
"$ref": "#/definitions/PluginAvailability"
}
],
"default": "AVAILABLE",
"description": "Availability state for installing and using the plugin."
},
"enabled": {
"type": "boolean"
},
@@ -12,6 +12,23 @@
],
"type": "string"
},
"PluginAvailability": {
"oneOf": [
{
"enum": [
"DISABLED_BY_ADMIN"
],
"type": "string"
},
{
"description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.",
"enum": [
"AVAILABLE"
],
"type": "string"
}
]
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
@@ -150,6 +167,31 @@
],
"type": "object"
},
"PluginShareListItem": {
"properties": {
"localPluginPath": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"plugin": {
"$ref": "#/definitions/PluginSummary"
},
"shareUrl": {
"type": "string"
}
},
"required": [
"plugin",
"shareUrl"
],
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -234,6 +276,15 @@
"authPolicy": {
"$ref": "#/definitions/PluginAuthPolicy"
},
"availability": {
"allOf": [
{
"$ref": "#/definitions/PluginAvailability"
}
],
"default": "AVAILABLE",
"description": "Availability state for installing and using the plugin."
},
"enabled": {
"type": "boolean"
},
@@ -278,7 +329,7 @@
"properties": {
"data": {
"items": {
"$ref": "#/definitions/PluginSummary"
"$ref": "#/definitions/PluginShareListItem"
},
"type": "array"
}
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
}
@@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Final process exit notification for `process/spawn`.",
"properties": {
"exitCode": {
"description": "Process exit code.",
"format": "int32",
"type": "integer"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stderr": {
"description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.",
"type": "string"
},
"stderrCapReached": {
"description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.",
"type": "boolean"
},
"stdout": {
"description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.",
"type": "string"
},
"stdoutCapReached": {
"description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.",
"type": "boolean"
}
},
"required": [
"exitCode",
"processHandle",
"stderr",
"stderrCapReached",
"stdout",
"stdoutCapReached"
],
"title": "ProcessExitedNotification",
"type": "object"
}
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ProcessOutputStream": {
"description": "Stream label for `process/outputDelta` notifications.",
"oneOf": [
{
"description": "stdout stream. PTY mode multiplexes terminal output here.",
"enum": [
"stdout"
],
"type": "string"
},
{
"description": "stderr stream.",
"enum": [
"stderr"
],
"type": "string"
}
]
}
},
"description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.",
"properties": {
"capReached": {
"description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.",
"type": "boolean"
},
"deltaBase64": {
"description": "Base64-encoded output bytes.",
"type": "string"
},
"processHandle": {
"description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.",
"type": "string"
},
"stream": {
"allOf": [
{
"$ref": "#/definitions/ProcessOutputStream"
}
],
"description": "Output stream this chunk belongs to."
}
},
"required": [
"capReached",
"deltaBase64",
"processHandle",
"stream"
],
"title": "ProcessOutputDeltaNotification",
"type": "object"
}
@@ -732,6 +732,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -190,10 +190,6 @@
"ephemeral": {
"type": "boolean"
},
"excludeTurns": {
"description": "When true, return only thread metadata and live fork state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after forking.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -862,6 +862,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -1045,10 +1067,6 @@
"null"
]
},
"excludeTurns": {
"description": "When true, return only thread metadata and live-resume state without populating `thread.turns`. This is useful when the client plans to call `thread/turns/list` immediately after resuming.",
"type": "boolean"
},
"model": {
"description": "Configuration overrides for the resumed thread, if any.",
"type": [
@@ -1,49 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"SortDirection": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
"properties": {
"cursor": {
"description": "Opaque cursor to pass to the next call to continue after the last turn.",
"type": [
"string",
"null"
]
},
"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."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnsListParams",
"type": "object"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"WindowsSandboxReadiness": {
"enum": [
"ready",
"notConfigured",
"updateRequired"
],
"type": "string"
}
},
"properties": {
"status": {
"$ref": "#/definitions/WindowsSandboxReadiness"
}
},
"required": [
"status"
],
"title": "WindowsSandboxReadinessResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "context_compaction", encrypted_content?: string, } | { "type": "other" };
File diff suppressed because one or more lines are too long
@@ -3,4 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadItem } from "./ThreadItem";
export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, };
export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string,
/**
* Unix timestamp (in milliseconds) when this item lifecycle completed.
*/
completedAtMs: number, };
@@ -3,4 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadItem } from "./ThreadItem";
export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, };
export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string,
/**
* Unix timestamp (in milliseconds) when this item lifecycle started.
*/
startedAtMs: number, };
+6 -1
View File
@@ -4,7 +4,12 @@
import type { InputModality } from "../InputModality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
import type { ModelServiceTier } from "./ModelServiceTier";
import type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
import type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, additionalSpeedTiers: Array<string>, isDefault: boolean, };
export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean,
/**
* Deprecated: use `serviceTiers` instead.
*/
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
@@ -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 ModelServiceTier = { id: string, name: string, description: string, };
@@ -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 PluginAvailability = "AVAILABLE" | "DISABLED_BY_ADMIN";
@@ -0,0 +1,7 @@
// 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 { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { PluginSummary } from "./PluginSummary";
export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, };
@@ -1,6 +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 { PluginSummary } from "./PluginSummary";
import type { PluginShareListItem } from "./PluginShareListItem";
export type PluginShareListResponse = { data: Array<PluginSummary>, };
export type PluginShareListResponse = { data: Array<PluginShareListItem>, };
@@ -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 PluginSkillReadParams = { remoteMarketplaceName: string, remotePluginId: string, skillName: string, };
@@ -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 PluginSkillReadResponse = { contents: string | null, };
@@ -2,8 +2,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginAvailability } from "./PluginAvailability";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, interface: PluginInterface | null, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy,
/**
* Availability state for installing and using the plugin.
*/
availability: PluginAvailability, interface: PluginInterface | null, };
@@ -0,0 +1,42 @@
// 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.
/**
* Final process exit notification for `process/spawn`.
*/
export type ProcessExitedNotification = {
/**
* Client-supplied, connection-scoped `processHandle` from `process/spawn`.
*/
processHandle: string,
/**
* Process exit code.
*/
exitCode: number,
/**
* Buffered stdout capture.
*
* Empty when stdout was streamed via `process/outputDelta`.
*/
stdout: string,
/**
* Whether stdout reached `outputBytesCap`.
*
* In streaming mode, stdout is empty and cap state is also reported on the
* final stdout `process/outputDelta` notification.
*/
stdoutCapReached: boolean,
/**
* Buffered stderr capture.
*
* Empty when stderr was streamed via `process/outputDelta`.
*/
stderr: string,
/**
* Whether stderr reached `outputBytesCap`.
*
* In streaming mode, stderr is empty and cap state is also reported on the
* final stderr `process/outputDelta` notification.
*/
stderrCapReached: boolean, };
@@ -0,0 +1,26 @@
// 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 { ProcessOutputStream } from "./ProcessOutputStream";
/**
* Base64-encoded output chunk emitted for a streaming `process/spawn` request.
*/
export type ProcessOutputDeltaNotification = {
/**
* Client-supplied, connection-scoped `processHandle` from `process/spawn`.
*/
processHandle: string,
/**
* Output stream this chunk belongs to.
*/
stream: ProcessOutputStream,
/**
* Base64-encoded output bytes.
*/
deltaBase64: string,
/**
* True on the final streamed chunk for this stream when output was
* truncated by `outputBytesCap`.
*/
capReached: boolean, };
@@ -0,0 +1,8 @@
// 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.
/**
* Stream label for `process/outputDelta` notifications.
*/
export type ProcessOutputStream = "stdout" | "stderr";
@@ -0,0 +1,16 @@
// 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.
/**
* PTY size in character cells for `process/spawn` PTY sessions.
*/
export type ProcessTerminalSize = {
/**
* Terminal height in character cells.
*/
rows: number,
/**
* Terminal width in character cells.
*/
cols: number, };
@@ -23,9 +23,4 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
* When true, return only thread metadata and live fork state without
* populating `thread.turns`. This is useful when the client plans to call
* `thread/turns/list` immediately after forking.
*/
excludeTurns?: boolean};
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean};
@@ -26,9 +26,4 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
* When true, return only thread metadata and live-resume state without
* populating `thread.turns`. This is useful when the client plans to call
* `thread/turns/list` immediately after resuming.
*/
excludeTurns?: boolean};
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null};
@@ -1,18 +0,0 @@
// 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";
export type ThreadTurnsListParams = { threadId: string,
/**
* Opaque cursor to pass to the next call to continue after the last turn.
*/
cursor?: string | null,
/**
* Optional turn page size.
*/
limit?: number | null,
/**
* Optional turn pagination direction; defaults to descending.
*/
sortDirection?: SortDirection | null, };
@@ -1,18 +0,0 @@
// 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 ThreadTurnsListResponse = { data: Array<Turn>,
/**
* Opaque cursor to pass to the next call to continue after the last turn.
* if None, there are no more turns to return.
*/
nextCursor: string | null,
/**
* Opaque cursor to pass as `cursor` when reversing `sortDirection`.
* This is only populated when the page contains at least one turn.
* Use it with the opposite `sortDirection` to include the anchor turn again
* and catch updates to that turn.
*/
backwardsCursor: string | null, };
@@ -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 WindowsSandboxReadiness = "ready" | "notConfigured" | "updateRequired";
@@ -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 { WindowsSandboxReadiness } from "./WindowsSandboxReadiness";
export type WindowsSandboxReadinessResponse = { status: WindowsSandboxReadiness, };
+11 -2
View File
@@ -245,6 +245,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil
export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse";
export type { ModelRerouteReason } from "./ModelRerouteReason";
export type { ModelReroutedNotification } from "./ModelReroutedNotification";
export type { ModelServiceTier } from "./ModelServiceTier";
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
export type { ModelVerification } from "./ModelVerification";
export type { ModelVerificationNotification } from "./ModelVerificationNotification";
@@ -270,6 +271,7 @@ export type { PermissionsRequestApprovalParams } from "./PermissionsRequestAppro
export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type { PluginAvailability } from "./PluginAvailability";
export type { PluginDetail } from "./PluginDetail";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
@@ -282,15 +284,22 @@ export type { PluginReadParams } from "./PluginReadParams";
export type { PluginReadResponse } from "./PluginReadResponse";
export type { PluginShareDeleteParams } from "./PluginShareDeleteParams";
export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse";
export type { PluginShareListItem } from "./PluginShareListItem";
export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
export type { PluginsMigration } from "./PluginsMigration";
export type { ProcessExitedNotification } from "./ProcessExitedNotification";
export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification";
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
@@ -395,8 +404,6 @@ export type { ThreadStatus } from "./ThreadStatus";
export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification";
export type { ThreadTokenUsage } from "./ThreadTokenUsage";
export type { ThreadTokenUsageUpdatedNotification } from "./ThreadTokenUsageUpdatedNotification";
export type { ThreadTurnsListParams } from "./ThreadTurnsListParams";
export type { ThreadTurnsListResponse } from "./ThreadTurnsListResponse";
export type { ThreadUnarchiveParams } from "./ThreadUnarchiveParams";
export type { ThreadUnarchiveResponse } from "./ThreadUnarchiveResponse";
export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotification";
@@ -429,6 +436,8 @@ export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WebSearchAction } from "./WebSearchAction";
export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness";
export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse";
export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification";
export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
@@ -80,6 +80,7 @@ pub enum ClientRequestSerializationScope {
Thread { thread_id: String },
ThreadPath { path: PathBuf },
CommandExecProcess { process_id: String },
Process { process_handle: String },
FuzzyFileSearchSession { session_id: String },
FsWatch { watch_id: String },
McpOauth { server_name: String },
@@ -127,6 +128,11 @@ macro_rules! serialization_scope_expr {
process_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, process_handle($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::Process {
process_handle: $actual_params.$field.clone(),
})
};
($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
session_id: $actual_params.$field.clone(),
@@ -564,6 +570,7 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadReadResponse,
},
#[experimental("thread/turns/list")]
ThreadTurnsList => "thread/turns/list" {
params: v2::ThreadTurnsListParams,
// Explicitly concurrent: this primarily reads append-only rollout storage.
@@ -611,6 +618,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginReadResponse,
},
PluginSkillRead => "plugin/skill/read" {
params: v2::PluginSkillReadParams,
serialization: global("config"),
response: v2::PluginSkillReadResponse,
},
PluginShareSave => "plugin/share/save" {
params: v2::PluginShareSaveParams,
serialization: global("config"),
@@ -831,6 +843,11 @@ client_request_definitions! {
serialization: global("windows-sandbox-setup"),
response: v2::WindowsSandboxSetupStartResponse,
},
WindowsSandboxReadiness => "windowsSandbox/readiness" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("config"),
response: v2::WindowsSandboxReadinessResponse,
},
LoginAccount => "account/login/start" {
params: v2::LoginAccountParams,
@@ -894,6 +911,34 @@ client_request_definitions! {
serialization: command_process_id(params.process_id),
response: v2::CommandExecResizeResponse,
},
#[experimental("process/spawn")]
/// Spawn a standalone process (argv vector) without a Codex sandbox.
ProcessSpawn => "process/spawn" {
params: v2::ProcessSpawnParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessSpawnResponse,
},
#[experimental("process/writeStdin")]
/// Write stdin bytes to a running `process/spawn` session or close stdin.
ProcessWriteStdin => "process/writeStdin" {
params: v2::ProcessWriteStdinParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessWriteStdinResponse,
},
#[experimental("process/kill")]
/// Terminate a running `process/spawn` session by client-supplied `processHandle`.
ProcessKill => "process/kill" {
params: v2::ProcessKillParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessKillResponse,
},
#[experimental("process/resizePty")]
/// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`.
ProcessResizePty => "process/resizePty" {
params: v2::ProcessResizePtyParams,
serialization: process_handle(params.process_handle),
response: v2::ProcessResizePtyResponse,
},
ConfigRead => "config/read" {
params: v2::ConfigReadParams,
@@ -1395,6 +1440,12 @@ server_notification_definitions! {
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
/// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
/// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.
#[experimental("process/outputDelta")]
ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification),
/// Final exit notification for a `process/spawn` session.
#[experimental("process/exited")]
ProcessExited => "process/exited" (v2::ProcessExitedNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
/// Deprecated legacy apply_patch output stream notification.
@@ -1,7 +1,6 @@
use crate::protocol::common::ServerNotification;
use crate::protocol::item_builders::build_command_execution_begin_item;
use crate::protocol::item_builders::build_command_execution_end_item;
use crate::protocol::item_builders::build_file_change_begin_item;
use crate::protocol::item_builders::convert_patch_changes;
use crate::protocol::v2::AgentMessageDeltaNotification;
use crate::protocol::v2::CollabAgentState;
@@ -13,9 +12,6 @@ use crate::protocol::v2::DynamicToolCallStatus;
use crate::protocol::v2::FileChangePatchUpdatedNotification;
use crate::protocol::v2::ItemCompletedNotification;
use crate::protocol::v2::ItemStartedNotification;
use crate::protocol::v2::McpToolCallError;
use crate::protocol::v2::McpToolCallResult;
use crate::protocol::v2::McpToolCallStatus;
use crate::protocol::v2::PlanDeltaNotification;
use crate::protocol::v2::ReasoningSummaryPartAddedNotification;
use crate::protocol::v2::ReasoningSummaryTextDeltaNotification;
@@ -24,7 +20,6 @@ use crate::protocol::v2::TerminalInteractionNotification;
use crate::protocol::v2::ThreadItem;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::protocol::EventMsg;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
/// Build the v2 app-server notification that directly corresponds to a single core event.
@@ -74,64 +69,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id: response.turn_id,
item,
})
}
EventMsg::McpToolCallBegin(begin_event) => {
let item = ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: begin_event.mcp_app_resource_uri,
result: None,
error: None,
duration_ms: None,
};
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id,
turn_id,
item,
})
}
EventMsg::McpToolCallEnd(end_event) => {
let status = if end_event.is_success() {
McpToolCallStatus::Completed
} else {
McpToolCallStatus::Failed
};
let duration_ms = i64::try_from(end_event.duration.as_millis()).ok();
let (result, error) = match &end_event.result {
Ok(value) => (
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
})),
None,
),
Err(message) => (
None,
Some(McpToolCallError {
message: message.clone(),
}),
),
};
let item = ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status,
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: end_event.mcp_app_resource_uri,
result,
error,
duration_ms,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id,
turn_id,
item,
completed_at_ms: response.completed_at_ms,
})
}
EventMsg::CollabAgentSpawnBegin(begin_event) => {
@@ -150,6 +88,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
}
EventMsg::CollabAgentSpawnEnd(end_event) => {
@@ -188,6 +127,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
}
EventMsg::CollabAgentInteractionBegin(begin_event) => {
@@ -207,6 +147,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
}
EventMsg::CollabAgentInteractionEnd(end_event) => {
@@ -234,6 +175,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
}
EventMsg::CollabWaitingBegin(begin_event) => {
@@ -257,6 +199,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
}
EventMsg::CollabWaitingEnd(end_event) => {
@@ -292,6 +235,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
}
EventMsg::CollabCloseBegin(begin_event) => {
@@ -310,6 +254,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
}
EventMsg::CollabCloseEnd(end_event) => {
@@ -342,6 +287,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
}
EventMsg::CollabResumeBegin(begin_event) => {
@@ -360,6 +306,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
}
EventMsg::CollabResumeEnd(end_event) => {
@@ -392,6 +339,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
}
EventMsg::AgentMessageContentDelta(event) => {
@@ -441,6 +389,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item: item_started_event.item.into(),
started_at_ms: item_started_event.started_at_ms,
})
}
EventMsg::ItemCompleted(item_completed_event) => {
@@ -448,13 +397,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item: item_completed_event.item.into(),
})
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id,
turn_id,
item: build_file_change_begin_item(&patch_begin_event),
completed_at_ms: item_completed_event.completed_at_ms,
})
}
EventMsg::PatchApplyUpdated(event) => {
@@ -470,6 +413,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item: build_command_execution_begin_item(&exec_command_begin_event),
started_at_ms: exec_command_begin_event.started_at_ms,
})
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
@@ -498,6 +442,7 @@ pub fn item_event_to_server_notification(
thread_id,
turn_id,
item: build_command_execution_end_item(&exec_command_end_event),
completed_at_ms: exec_command_end_event.completed_at_ms,
})
}
_ => unreachable!("unsupported item event"),
@@ -508,17 +453,11 @@ pub fn item_event_to_server_notification(
mod tests {
use super::*;
use codex_protocol::ThreadId;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::protocol::CollabResumeBeginEvent;
use codex_protocol::protocol::CollabResumeEndEvent;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use pretty_assertions::assert_eq;
use rmcp::model::Content;
use std::time::Duration;
fn assert_item_started_server_notification(
notification: ServerNotification,
@@ -556,6 +495,7 @@ mod tests {
fn collab_resume_begin_maps_to_item_started_resume_agent() {
let event = CollabResumeBeginEvent {
call_id: "call-1".to_string(),
started_at_ms: 123,
sender_thread_id: ThreadId::new(),
receiver_thread_id: ThreadId::new(),
receiver_agent_nickname: None,
@@ -572,6 +512,7 @@ mod tests {
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: event.started_at_ms,
item: ThreadItem::CollabAgentToolCall {
id: event.call_id,
tool: CollabAgentTool::ResumeAgent,
@@ -591,6 +532,7 @@ mod tests {
fn collab_resume_end_maps_to_item_completed_resume_agent() {
let event = CollabResumeEndEvent {
call_id: "call-2".to_string(),
completed_at_ms: 456,
sender_thread_id: ThreadId::new(),
receiver_thread_id: ThreadId::new(),
receiver_agent_nickname: None,
@@ -609,6 +551,7 @@ mod tests {
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: event.completed_at_ms,
item: ThreadItem::CollabAgentToolCall {
id: event.call_id,
tool: CollabAgentTool::ResumeAgent,
@@ -629,179 +572,6 @@ mod tests {
);
}
#[test]
fn mcp_tool_call_begin_maps_to_item_started_notification_with_args() {
let begin_event = McpToolCallBeginEvent {
call_id: "call_123".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-1",
"turn_1",
);
assert_item_started_server_notification(
notification,
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn_1".to_string(),
item: ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: None,
error: None,
duration_ms: None,
},
},
);
}
#[test]
fn mcp_tool_call_begin_maps_to_item_started_notification_without_args() {
let begin_event = McpToolCallBeginEvent {
call_id: "call_456".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-2",
"turn_2",
);
assert_item_started_server_notification(
notification,
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn_2".to_string(),
item: ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
},
},
);
}
#[test]
fn mcp_tool_call_end_maps_to_item_completed_notification_on_success() {
let content = vec![
serde_json::to_value(Content::text("{\"resources\":[]}"))
.expect("content should serialize"),
];
let result = CallToolResult {
content: content.clone(),
is_error: Some(false),
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
};
let end_event = McpToolCallEndEvent {
call_id: "call_789".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
duration: Duration::from_nanos(92708),
result: Ok(result),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-3",
"turn_3",
);
assert_item_completed_server_notification(
notification,
ItemCompletedNotification {
thread_id: "thread-3".to_string(),
turn_id: "turn_3".to_string(),
item: ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: Some(Box::new(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
})),
error: None,
duration_ms: Some(0),
},
},
);
}
#[test]
fn mcp_tool_call_end_maps_to_item_completed_notification_on_error() {
let end_event = McpToolCallEndEvent {
call_id: "call_err".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(1),
result: Err("boom".to_string()),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-4",
"turn_4",
);
assert_item_completed_server_notification(
notification,
ItemCompletedNotification {
thread_id: "thread-4".to_string(),
turn_id: "turn_4".to_string(),
item: ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status: McpToolCallStatus::Failed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".to_string(),
}),
duration_ms: Some(1),
},
},
);
}
#[test]
fn exec_command_output_delta_maps_to_command_execution_output_delta() {
let notification = item_event_to_server_notification(
@@ -1,9 +1,8 @@
//! Shared builders for synthetic [`ThreadItem`] values emitted by the app-server layer.
//! Shared builders for app-server [`ThreadItem`] values derived from compatibility events.
//!
//! These items do not come from first-class core `ItemStarted` / `ItemCompleted` events.
//! Instead, the app-server synthesizes them so clients can render a coherent lifecycle for
//! approvals and other pre-execution flows before the underlying tool has started or when the
//! tool never starts at all.
//! Most live tool items now come from first-class core `ItemStarted` / `ItemCompleted` events.
//! These builders remain for approval flows, rebuilt legacy history, and other pre-execution
//! paths where the underlying tool has not started or never starts at all.
//!
//! Keeping these builders in one place is useful for two reasons:
//! - Live notifications and rebuilt `thread/read` history both need to construct the same
@@ -217,7 +217,6 @@ impl ThreadHistoryBuilder {
EventMsg::Error(payload) => self.handle_error(payload),
EventMsg::TokenCount(_) => {}
EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload),
EventMsg::UndoCompleted(_) => {}
EventMsg::TurnAborted(payload) => self.handle_turn_aborted(payload),
EventMsg::TurnStarted(payload) => self.handle_turn_started(payload),
EventMsg::TurnComplete(payload) => self.handle_turn_complete(payload),
@@ -357,7 +356,10 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageView(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -378,7 +380,10 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageView(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -1351,6 +1356,7 @@ mod tests {
id: "user-item-id".to_string(),
content: Vec::new(),
}),
started_at_ms: 0,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
@@ -1815,6 +1821,7 @@ mod tests {
call_id: "exec-1".into(),
process_id: Some("pid-1".into()),
turn_id: "turn-1".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "hello world".into()],
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
@@ -1978,6 +1985,7 @@ mod tests {
codex_protocol::dynamic_tools::DynamicToolCallRequest {
call_id: "dyn-1".into(),
turn_id: "turn-1".into(),
started_at_ms: 0,
namespace: Some("codex_app".into()),
tool: "lookup_ticket".into(),
arguments: serde_json::json!({"id":"ABC-123"}),
@@ -1986,6 +1994,7 @@ mod tests {
EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id: "dyn-1".into(),
turn_id: "turn-1".into(),
completed_at_ms: 0,
namespace: Some("codex_app".into()),
tool: "lookup_ticket".into(),
arguments: serde_json::json!({"id":"ABC-123"}),
@@ -2041,6 +2050,7 @@ mod tests {
call_id: "exec-declined".into(),
process_id: Some("pid-2".into()),
turn_id: "turn-1".into(),
completed_at_ms: 0,
command: vec!["ls".into()],
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }],
@@ -2288,6 +2298,7 @@ mod tests {
call_id: "exec-late".into(),
process_id: Some("pid-42".into()),
turn_id: "turn-a".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "done".into()],
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
@@ -2379,6 +2390,7 @@ mod tests {
call_id: "exec-unknown-turn".into(),
process_id: Some("pid-42".into()),
turn_id: "turn-missing".into(),
completed_at_ms: 0,
command: vec!["echo".into(), "done".into()],
cwd: test_path_buf("/tmp").abs(),
parsed_cmd: vec![ParsedCommand::Unknown {
@@ -2727,6 +2739,7 @@ mod tests {
}),
EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent {
call_id: "resume-1".into(),
completed_at_ms: 0,
sender_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
.expect("valid sender thread id"),
receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002")
@@ -2783,6 +2796,7 @@ mod tests {
}),
EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent {
call_id: "spawn-1".into(),
completed_at_ms: 0,
sender_thread_id,
new_thread_id: Some(spawned_thread_id),
new_agent_nickname: Some("Scout".into()),
@@ -2844,6 +2858,7 @@ mod tests {
EventMsg::CollabAgentInteractionBegin(
codex_protocol::protocol::CollabAgentInteractionBeginEvent {
call_id: "send-1".into(),
started_at_ms: 0,
sender_thread_id: sender,
receiver_thread_id: receiver,
prompt: "new task".into(),
@@ -2852,6 +2867,7 @@ mod tests {
EventMsg::CollabAgentInteractionEnd(
codex_protocol::protocol::CollabAgentInteractionEndEvent {
call_id: "send-1".into(),
completed_at_ms: 0,
sender_thread_id: sender,
receiver_thread_id: receiver,
receiver_agent_nickname: None,
+727 -25
View File
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use crate::RequestId;
use crate::protocol::common::AuthMode;
use crate::protocol::item_builders::convert_patch_changes;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::account::ProviderAccount;
@@ -30,6 +31,8 @@ use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::McpToolCallError as CoreMcpToolCallError;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult;
use codex_protocol::mcp::Resource as McpResource;
@@ -2508,6 +2511,15 @@ impl From<CoreModelAvailabilityNux> for ModelAvailabilityNux {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelServiceTier {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2526,8 +2538,11 @@ pub struct Model {
pub input_modalities: Vec<InputModality>,
#[serde(default)]
pub supports_personality: bool,
/// Deprecated: use `serviceTiers` instead.
#[serde(default)]
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -2782,6 +2797,24 @@ impl From<CoreMcpCallToolResult> for McpServerToolCallResponse {
}
}
impl From<CoreMcpCallToolResult> for McpToolCallResult {
fn from(result: CoreMcpCallToolResult) -> Self {
Self {
content: result.content,
structured_content: result.structured_content,
meta: result.meta,
}
}
}
impl From<CoreMcpToolCallError> for McpToolCallError {
fn from(error: CoreMcpToolCallError) -> Self {
Self {
message: error.message,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3537,6 +3570,204 @@ pub enum CommandExecOutputStream {
Stderr,
}
/// PTY size in character cells for `process/spawn` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessTerminalSize {
/// Terminal height in character cells.
pub rows: u16,
/// Terminal width in character cells.
pub cols: u16,
}
/// Spawn a standalone process (argv vector) without a Codex sandbox on the host
/// where the app server is running.
///
/// `process/spawn` returns after the process has started and the connection-scoped
/// `processHandle` has been registered. Process output and exit are reported via
/// `process/outputDelta` and `process/exited` notifications.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessSpawnParams {
/// Command argv vector. Empty arrays are rejected.
pub command: Vec<String>,
/// Client-supplied, connection-scoped process handle.
///
/// Duplicate active handles are rejected on the same connection. The same
/// handle can be reused after the prior process exits.
pub process_handle: String,
/// Absolute working directory for the process.
pub cwd: AbsolutePathBuf,
/// Enable PTY mode.
///
/// This implies `streamStdin` and `streamStdoutStderr`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tty: bool,
/// Allow follow-up `process/writeStdin` requests to write stdin bytes.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdin: bool,
/// Stream stdout/stderr via `process/outputDelta` notifications.
///
/// Streamed bytes are not duplicated into the `process/exited` notification.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream_stdout_stderr: bool,
/// Optional per-stream stdout/stderr capture cap in bytes.
///
/// When omitted, the server default applies. Set to `null` to disable the
/// cap.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub output_bytes_cap: Option<Option<usize>>,
/// Optional timeout in milliseconds.
///
/// When omitted, the server default applies. Set to `null` to disable the
/// timeout.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub timeout_ms: Option<Option<i64>>,
/// Optional environment overrides merged into the app-server process
/// environment.
///
/// Matching names override inherited values. Set a key to `null` to unset
/// an inherited variable.
#[ts(optional = nullable)]
pub env: Option<HashMap<String, Option<String>>>,
/// Optional initial PTY size in character cells. Only valid when `tty` is
/// true.
#[ts(optional = nullable)]
pub size: Option<ProcessTerminalSize>,
}
/// Successful response for `process/spawn`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessSpawnResponse {}
/// Write stdin bytes to a running `process/spawn` session, close stdin, or
/// both.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessWriteStdinParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Optional base64-encoded stdin bytes to write.
#[ts(optional = nullable)]
pub delta_base64: Option<String>,
/// Close stdin after writing `deltaBase64`, if present.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub close_stdin: bool,
}
/// Empty success response for `process/writeStdin`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessWriteStdinResponse {}
/// Terminate a running `process/spawn` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessKillParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
}
/// Empty success response for `process/kill`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessKillResponse {}
/// Resize a running PTY-backed `process/spawn` session.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessResizePtyParams {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// New PTY size in character cells.
pub size: ProcessTerminalSize,
}
/// Empty success response for `process/resizePty`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessResizePtyResponse {}
/// Stream label for `process/outputDelta` notifications.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ProcessOutputStream {
/// stdout stream. PTY mode multiplexes terminal output here.
Stdout,
/// stderr stream.
Stderr,
}
/// Base64-encoded output chunk emitted for a streaming `process/spawn` request.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessOutputDeltaNotification {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Output stream this chunk belongs to.
pub stream: ProcessOutputStream,
/// Base64-encoded output bytes.
pub delta_base64: String,
/// True on the final streamed chunk for this stream when output was
/// truncated by `outputBytesCap`.
pub cap_reached: bool,
}
/// Final process exit notification for `process/spawn`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProcessExitedNotification {
/// Client-supplied, connection-scoped `processHandle` from `process/spawn`.
pub process_handle: String,
/// Process exit code.
pub exit_code: i32,
/// Buffered stdout capture.
///
/// Empty when stdout was streamed via `process/outputDelta`.
pub stdout: String,
/// Whether stdout reached `outputBytesCap`.
///
/// In streaming mode, stdout is empty and cap state is also reported on the
/// final stdout `process/outputDelta` notification.
pub stdout_cap_reached: bool,
/// Buffered stderr capture.
///
/// Empty when stderr was streamed via `process/outputDelta`.
pub stderr: String,
/// Whether stderr reached `outputBytesCap`.
///
/// In streaming mode, stderr is empty and cap state is also reported on the
/// final stderr `process/outputDelta` notification.
pub stderr_cap_reached: bool,
}
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(
@@ -3610,8 +3841,9 @@ pub struct ThreadStartParams {
#[experimental("thread/start.experimentalRawEvents")]
#[serde(default)]
pub experimental_raw_events: bool,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on resume/fork/read.
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
#[experimental("thread/start.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -3738,10 +3970,12 @@ pub struct ThreadResumeParams {
/// When true, return only thread metadata and live-resume state without
/// populating `thread.turns`. This is useful when the client plans to call
/// `thread/turns/list` immediately after resuming.
#[experimental("thread/resume.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
#[experimental("thread/resume.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -3842,10 +4076,12 @@ pub struct ThreadForkParams {
/// When true, return only thread metadata and live fork state without
/// populating `thread.turns`. This is useful when the client plans to call
/// `thread/turns/list` immediately after forking.
#[experimental("thread/fork.excludeTurns")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub exclude_turns: bool,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
/// If true, persist additional EventMsg variants to the rollout file.
/// However, `thread/read`, `thread/resume`, and `thread/fork` still only
/// return the limited form of thread history for scalability reasons.
#[experimental("thread/fork.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
@@ -4607,6 +4843,22 @@ pub struct PluginReadResponse {
pub plugin: PluginDetail,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSkillReadParams {
pub remote_marketplace_name: String,
pub remote_plugin_id: String,
pub skill_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSkillReadResponse {
pub contents: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4633,7 +4885,7 @@ pub struct PluginShareListParams {}
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListResponse {
pub data: Vec<PluginSummary>,
pub data: Vec<PluginShareListItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -4648,6 +4900,15 @@ pub struct PluginShareDeleteParams {
#[ts(export_to = "v2/")]
pub struct PluginShareDeleteResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginShareListItem {
pub plugin: PluginSummary,
pub share_url: String,
pub local_plugin_path: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -4825,6 +5086,21 @@ pub enum PluginAuthPolicy {
OnUse,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginAvailability {
/// Plugin-service currently sends `"ENABLED"` for available remote plugins.
/// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps
/// decoding compatible with that upstream response.
#[serde(rename = "AVAILABLE", alias = "ENABLED")]
#[ts(rename = "AVAILABLE")]
#[default]
Available,
#[serde(rename = "DISABLED_BY_ADMIN")]
#[ts(rename = "DISABLED_BY_ADMIN")]
DisabledByAdmin,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -4836,6 +5112,9 @@ pub struct PluginSummary {
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub auth_policy: PluginAuthPolicy,
/// Availability state for installing and using the plugin.
#[serde(default)]
pub availability: PluginAvailability,
pub interface: Option<PluginInterface>,
}
@@ -6417,6 +6696,10 @@ impl From<CoreTurnItem> for ThreadItem {
query: search.query,
action: Some(WebSearchAction::from(search.action)),
},
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
id: image.id,
path: image.path,
},
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
id: image.id,
status: image.status,
@@ -6424,6 +6707,32 @@ impl From<CoreTurnItem> for ThreadItem {
result: image.result,
saved_path: image.saved_path,
},
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
changes: convert_patch_changes(&file_change.changes),
status: file_change
.status
.as_ref()
.map(PatchApplyStatus::from)
.unwrap_or(PatchApplyStatus::InProgress),
},
CoreTurnItem::McpToolCall(mcp) => {
let duration_ms = mcp
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok());
ThreadItem::McpToolCall {
id: mcp.id,
server: mcp.server,
tool: mcp.tool,
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
error: mcp.error.map(McpToolCallError::from),
duration_ms,
}
}
CoreTurnItem::ContextCompaction(compaction) => {
ThreadItem::ContextCompaction { id: compaction.id }
}
@@ -6533,6 +6842,16 @@ impl From<&CorePatchApplyStatus> for PatchApplyStatus {
}
}
impl From<CoreMcpToolCallStatus> for McpToolCallStatus {
fn from(value: CoreMcpToolCallStatus) -> Self {
match value {
CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress,
CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed,
CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6819,6 +7138,9 @@ pub struct ItemStartedNotification {
pub item: ThreadItem,
pub thread_id: String,
pub turn_id: String,
/// Unix timestamp (in milliseconds) when this item lifecycle started.
#[ts(type = "number")]
pub started_at_ms: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -6881,6 +7203,9 @@ pub struct ItemCompletedNotification {
pub item: ThreadItem,
pub thread_id: String,
pub turn_id: String,
/// Unix timestamp (in milliseconds) when this item lifecycle completed.
#[ts(type = "number")]
pub completed_at_ms: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -7080,6 +7405,15 @@ pub enum WindowsSandboxSetupMode {
Unelevated,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum WindowsSandboxReadiness {
Ready,
NotConfigured,
UpdateRequired,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -7096,6 +7430,13 @@ pub struct WindowsSandboxSetupStartResponse {
pub started: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct WindowsSandboxReadinessResponse {
pub status: WindowsSandboxReadiness,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8033,10 +8374,15 @@ mod tests {
use super::*;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ImageViewItem;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::user_input::UserInput as CoreUserInput;
@@ -8046,6 +8392,7 @@ mod tests {
use serde_json::json;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::time::Duration;
fn absolute_path_string(path: &str) -> String {
let path = format!("/{}", path.trim_start_matches('/'));
@@ -9026,6 +9373,97 @@ mod tests {
assert_eq!(decoded, params);
}
#[test]
fn process_spawn_params_round_trips_without_sandbox_policy() {
let params = ProcessSpawnParams {
command: vec!["sleep".to_string(), "30".to_string()],
process_handle: "sleep-1".to_string(),
cwd: test_absolute_path(),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
timeout_ms: None,
env: None,
size: None,
};
let value = serde_json::to_value(&params).expect("serialize process/spawn params");
assert_eq!(
value,
json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"env": null,
"size": null,
})
);
let decoded =
serde_json::from_value::<ProcessSpawnParams>(value).expect("deserialize round-trip");
assert_eq!(decoded, params);
}
#[test]
fn process_spawn_params_distinguish_omitted_null_and_value_limits() {
let base = json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
});
let expected_omitted = ProcessSpawnParams {
command: vec!["sleep".to_string(), "30".to_string()],
process_handle: "sleep-1".to_string(),
cwd: test_absolute_path(),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
timeout_ms: None,
env: None,
size: None,
};
let decoded =
serde_json::from_value::<ProcessSpawnParams>(base).expect("deserialize omitted limits");
assert_eq!(decoded, expected_omitted);
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"outputBytesCap": null,
"timeoutMs": null,
}))
.expect("deserialize disabled limits");
assert_eq!(
decoded,
ProcessSpawnParams {
output_bytes_cap: Some(None),
timeout_ms: Some(None),
..expected_omitted.clone()
}
);
let decoded = serde_json::from_value::<ProcessSpawnParams>(json!({
"command": ["sleep", "30"],
"processHandle": "sleep-1",
"cwd": absolute_path_string("readable"),
"outputBytesCap": 123,
"timeoutMs": 456,
}))
.expect("deserialize explicit limits");
assert_eq!(
decoded,
ProcessSpawnParams {
output_bytes_cap: Some(Some(123)),
timeout_ms: Some(Some(456)),
..expected_omitted
}
);
}
#[test]
fn command_exec_params_round_trips_disable_output_cap() {
let params = CommandExecParams {
@@ -9258,6 +9696,110 @@ mod tests {
assert_eq!(decoded, notification);
}
#[test]
fn process_control_params_round_trip() {
let write = ProcessWriteStdinParams {
process_handle: "proc-7".to_string(),
delta_base64: None,
close_stdin: true,
};
let value = serde_json::to_value(&write).expect("serialize process/writeStdin params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
"deltaBase64": null,
"closeStdin": true,
})
);
let decoded = serde_json::from_value::<ProcessWriteStdinParams>(value)
.expect("deserialize process/writeStdin params");
assert_eq!(decoded, write);
let resize = ProcessResizePtyParams {
process_handle: "proc-7".to_string(),
size: ProcessTerminalSize {
rows: 50,
cols: 160,
},
};
let value = serde_json::to_value(&resize).expect("serialize process/resizePty params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
"size": {
"rows": 50,
"cols": 160,
},
})
);
let decoded = serde_json::from_value::<ProcessResizePtyParams>(value)
.expect("deserialize process/resizePty params");
assert_eq!(decoded, resize);
let kill = ProcessKillParams {
process_handle: "proc-7".to_string(),
};
let value = serde_json::to_value(&kill).expect("serialize process/kill params");
assert_eq!(
value,
json!({
"processHandle": "proc-7",
})
);
let decoded =
serde_json::from_value::<ProcessKillParams>(value).expect("deserialize process/kill");
assert_eq!(decoded, kill);
}
#[test]
fn process_notifications_round_trip() {
let delta = ProcessOutputDeltaNotification {
process_handle: "proc-1".to_string(),
stream: ProcessOutputStream::Stdout,
delta_base64: "AQI=".to_string(),
cap_reached: false,
};
let value = serde_json::to_value(&delta).expect("serialize process/outputDelta");
assert_eq!(
value,
json!({
"processHandle": "proc-1",
"stream": "stdout",
"deltaBase64": "AQI=",
"capReached": false,
})
);
let decoded = serde_json::from_value::<ProcessOutputDeltaNotification>(value)
.expect("deserialize process/outputDelta");
assert_eq!(decoded, delta);
let exited = ProcessExitedNotification {
process_handle: "proc-1".to_string(),
exit_code: 0,
stdout: "out".to_string(),
stdout_cap_reached: false,
stderr: "err".to_string(),
stderr_cap_reached: true,
};
let value = serde_json::to_value(&exited).expect("serialize process/exited");
assert_eq!(
value,
json!({
"processHandle": "proc-1",
"exitCode": 0,
"stdout": "out",
"stdoutCapReached": false,
"stderr": "err",
"stderrCapReached": true,
})
);
let decoded = serde_json::from_value::<ProcessExitedNotification>(value)
.expect("deserialize process/exited");
assert_eq!(decoded, exited);
}
#[test]
fn command_execution_output_delta_round_trips() {
let notification = CommandExecutionOutputDeltaNotification {
@@ -10313,6 +10855,111 @@ mod tests {
}),
}
);
let image_view_item = TurnItem::ImageView(ImageViewItem {
id: "view-image-1".to_string(),
path: test_path_buf("/tmp/view-image.png").abs(),
});
assert_eq!(
ThreadItem::from(image_view_item),
ThreadItem::ImageView {
id: "view-image-1".to_string(),
path: test_path_buf("/tmp/view-image.png").abs(),
}
);
let file_change_item = TurnItem::FileChange(FileChangeItem {
id: "patch-1".to_string(),
changes: [(
PathBuf::from("README.md"),
codex_protocol::protocol::FileChange::Add {
content: "hello\n".to_string(),
},
)]
.into_iter()
.collect(),
status: Some(codex_protocol::protocol::PatchApplyStatus::Completed),
auto_approved: None,
stdout: Some("Done!".to_string()),
stderr: Some(String::new()),
});
assert_eq!(
ThreadItem::from(file_change_item),
ThreadItem::FileChange {
id: "patch-1".to_string(),
changes: vec![FileUpdateChange {
path: "README.md".to_string(),
kind: PatchChangeKind::Add,
diff: "hello\n".to_string(),
}],
status: PatchApplyStatus::Completed,
}
);
let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
});
assert_eq!(
ThreadItem::from(mcp_tool_call_item),
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
result: None,
error: None,
duration_ms: None,
}
);
let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-2".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
is_error: Some(false),
meta: Some(json!({"trace": "1"})),
}),
error: None,
duration: Some(Duration::from_millis(42)),
});
assert_eq!(
ThreadItem::from(completed_mcp_tool_call_item),
ThreadItem::McpToolCall {
id: "mcp-2".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
meta: Some(json!({"trace": "1"})),
})),
error: None,
duration_ms: Some(42),
}
);
}
#[test]
@@ -10647,6 +11294,23 @@ mod tests {
);
}
#[test]
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
assert_eq!(
serde_json::to_value(PluginSkillReadParams {
remote_marketplace_name: "chatgpt-global".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
.unwrap(),
json!({
"remoteMarketplaceName": "chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"skillName": "plan-work",
}),
);
}
#[test]
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
let plugin_path = if cfg!(windows) {
@@ -10712,36 +11376,74 @@ mod tests {
}
#[test]
fn plugin_share_list_response_serializes_plugin_summaries() {
fn plugin_share_list_response_serializes_share_items() {
assert_eq!(
serde_json::to_value(PluginShareListResponse {
data: vec![PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
name: "gmail".to_string(),
source: PluginSource::Remote,
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: None,
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
name: "gmail".to_string(),
source: PluginSource::Remote,
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: None,
},
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
local_plugin_path: None,
}],
})
.unwrap(),
json!({
"data": [{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"interface": null,
"plugin": {
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"availability": "AVAILABLE",
"interface": null,
},
"shareUrl": "https://chatgpt.example/plugins/share/share-key-1",
"localPluginPath": null,
}],
}),
);
}
#[test]
fn plugin_summary_defaults_missing_availability_to_available() {
let summary: PluginSummary = serde_json::from_value(json!({
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "gmail",
"source": { "type": "remote" },
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"authPolicy": "ON_USE",
"interface": null,
}))
.unwrap();
assert_eq!(summary.availability, PluginAvailability::Available);
}
#[test]
fn plugin_availability_deserializes_enabled_alias() {
let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap();
assert_eq!(availability, PluginAvailability::Available);
assert_eq!(
serde_json::to_value(availability).unwrap(),
json!("AVAILABLE")
);
}
#[test]
fn plugin_uninstall_params_serialization_omits_force_remote_sync() {
assert_eq!(
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "app-server-transport",
crate_name = "codex_app_server_transport",
)
+58
View File
@@ -0,0 +1,58 @@
[package]
name = "codex-app-server-transport"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_app_server_transport"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, default-features = false, features = [
"http1",
"json",
"tokio",
"ws",
] }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-core = { workspace = true }
codex-login = { workspace = true }
codex-model-provider = { workspace = true }
codex-state = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"rt-multi-thread",
] }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true, features = ["log"] }
url = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
chrono = { workspace = true }
codex-config = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+20
View File
@@ -0,0 +1,20 @@
mod outgoing_message;
mod transport;
pub use outgoing_message::ConnectionId;
pub use outgoing_message::OutgoingError;
pub use outgoing_message::OutgoingMessage;
pub use outgoing_message::OutgoingResponse;
pub use outgoing_message::QueuedOutgoingMessage;
pub use transport::AppServerTransport;
pub use transport::AppServerTransportParseError;
pub use transport::CHANNEL_CAPACITY;
pub use transport::ConnectionOrigin;
pub use transport::RemoteControlHandle;
pub use transport::TransportEvent;
pub use transport::app_server_control_socket_path;
pub use transport::auth;
pub use transport::start_control_socket_acceptor;
pub use transport::start_remote_control;
pub use transport::start_stdio_connection;
pub use transport::start_websocket_acceptor;
@@ -0,0 +1,58 @@
use std::fmt;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::Result;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use serde::Serialize;
use tokio::sync::oneshot;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ConnectionId(pub u64);
impl fmt::Display for ConnectionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Outgoing message from the server to the client.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum OutgoingMessage {
Request(ServerRequest),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
#[derive(Debug)]
pub struct QueuedOutgoingMessage {
pub message: OutgoingMessage,
pub write_complete_tx: Option<oneshot::Sender<()>>,
}
impl QueuedOutgoingMessage {
pub fn new(message: OutgoingMessage) -> Self {
Self {
message,
write_complete_tx: None,
}
}
}
@@ -86,7 +86,7 @@ pub enum AppServerWebsocketCapabilityTokenSource {
}
#[derive(Clone, Debug, Default)]
pub(crate) struct WebsocketAuthPolicy {
pub struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
}
@@ -219,7 +219,7 @@ impl AppServerWebsocketAuthArgs {
}
}
pub(crate) fn policy_from_settings(
pub fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
@@ -0,0 +1,478 @@
pub mod auth;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::QueuedOutgoingMessage;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_core::config::find_codex_home;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::warn;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage - 128 messages should be
/// plenty for an interactive CLI.
pub const CHANNEL_CAPACITY: usize = 128;
mod remote_control;
mod stdio;
mod unix_socket;
#[cfg(test)]
mod unix_socket_tests;
mod websocket;
pub use remote_control::RemoteControlHandle;
pub use remote_control::start_remote_control;
pub use stdio::start_stdio_connection;
pub use unix_socket::start_control_socket_acceptor;
pub use websocket::start_websocket_acceptor;
const OVERLOADED_ERROR_CODE: i64 = -32001;
const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control";
const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock";
pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(
codex_home
.join(APP_SERVER_CONTROL_SOCKET_DIR_NAME)
.join(APP_SERVER_CONTROL_SOCKET_FILE_NAME),
)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AppServerTransport {
Stdio,
UnixSocket { socket_path: AbsolutePathBuf },
WebSocket { bind_address: SocketAddr },
Off,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AppServerTransportParseError {
UnsupportedListenUrl(String),
InvalidUnixSocketPath { listen_url: String, message: String },
InvalidWebSocketListenUrl(String),
}
impl std::fmt::Display for AppServerTransportParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`"
),
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url,
message,
} => write!(
f,
"invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}"
),
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
),
}
}
}
impl std::error::Error for AppServerTransportParseError {}
impl AppServerTransport {
pub const DEFAULT_LISTEN_URL: &'static str = "stdio://";
pub fn from_listen_url(listen_url: &str) -> Result<Self, AppServerTransportParseError> {
if listen_url == Self::DEFAULT_LISTEN_URL {
return Ok(Self::Stdio);
}
if let Some(raw_socket_path) = listen_url.strip_prefix("unix://") {
let socket_path = if raw_socket_path.is_empty() {
let codex_home = find_codex_home().map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: format!("failed to resolve CODEX_HOME: {err}"),
}
})?;
app_server_control_socket_path(&codex_home).map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: err.to_string(),
}
})?
} else {
AbsolutePathBuf::relative_to_current_dir(raw_socket_path).map_err(|err| {
AppServerTransportParseError::InvalidUnixSocketPath {
listen_url: listen_url.to_string(),
message: err.to_string(),
}
})?
};
return Ok(Self::UnixSocket { socket_path });
}
if listen_url == "off" {
return Ok(Self::Off);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
})?;
return Ok(Self::WebSocket { bind_address });
}
Err(AppServerTransportParseError::UnsupportedListenUrl(
listen_url.to_string(),
))
}
}
impl FromStr for AppServerTransport {
type Err = AppServerTransportParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_listen_url(s)
}
}
#[derive(Debug)]
pub enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
origin: ConnectionOrigin,
writer: mpsc::Sender<QueuedOutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
connection_id: ConnectionId,
},
IncomingMessage {
connection_id: ConnectionId,
message: JSONRPCMessage,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionOrigin {
Stdio,
InProcess,
WebSocket,
RemoteControl,
}
impl ConnectionOrigin {
pub fn allows_device_key_requests(self) -> bool {
// Device-key endpoints are only for local connections that own the app-server instance.
// Do not include remote transports such as SSH or remote-control websocket connections.
matches!(self, Self::Stdio | Self::InProcess)
}
}
static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
fn next_connection_id() -> ConnectionId {
ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
}
async fn forward_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
payload: &str,
) -> bool {
match serde_json::from_str::<JSONRPCMessage>(payload) {
Ok(message) => {
enqueue_incoming_message(transport_event_tx, writer, connection_id, message).await
}
Err(err) => {
error!("Failed to deserialize JSONRPCMessage: {err}");
true
}
}
}
async fn enqueue_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
writer: &mpsc::Sender<QueuedOutgoingMessage>,
connection_id: ConnectionId,
message: JSONRPCMessage,
) -> bool {
let event = TransportEvent::IncomingMessage {
connection_id,
message,
};
match transport_event_tx.try_send(event) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
Err(mpsc::error::TrySendError::Full(TransportEvent::IncomingMessage {
connection_id,
message: JSONRPCMessage::Request(request),
})) => {
let overload_error = OutgoingMessage::Error(OutgoingError {
id: request.id,
error: JSONRPCErrorError {
code: OVERLOADED_ERROR_CODE,
message: "Server overloaded; retry later.".to_string(),
data: None,
},
});
match writer.try_send(QueuedOutgoingMessage::new(overload_error)) {
Ok(()) => true,
Err(mpsc::error::TrySendError::Closed(_)) => false,
Err(mpsc::error::TrySendError::Full(_overload_error)) => {
warn!(
"dropping overload response for connection {:?}: outbound queue is full",
connection_id
);
true
}
}
}
Err(mpsc::error::TrySendError::Full(event)) => transport_event_tx.send(event).await.is_ok(),
}
}
fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option<String> {
let value = match serde_json::to_value(outgoing_message) {
Ok(value) => value,
Err(err) => {
error!("Failed to convert OutgoingMessage to JSON value: {err}");
return None;
}
};
match serde_json::to_string(&value) {
Ok(json) => Some(json),
Err(err) => {
error!("Failed to serialize JSONRPCMessage: {err}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::Duration;
use tokio::time::timeout;
#[test]
fn listen_off_parses_as_off_transport() {
assert_eq!(
AppServerTransport::from_listen_url("off"),
Ok(AppServerTransport::Off)
);
}
#[tokio::test]
async fn enqueue_incoming_request_returns_overload_error_when_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: first_message.clone(),
})
.await
.expect("queue should accept first message");
let request = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
assert!(
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request).await
);
let queued_event = transport_event_rx
.recv()
.await
.expect("first event should stay queued");
match queued_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message,
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(message, first_message);
}
_ => panic!("expected queued incoming message"),
}
let overload = writer_rx
.recv()
.await
.expect("request should receive overload error");
let overload_json =
serde_json::to_value(overload.message).expect("serialize overload error");
assert_eq!(
overload_json,
json!({
"id": 7,
"error": {
"code": OVERLOADED_ERROR_CODE,
"message": "Server overloaded; retry later."
}
})
);
}
#[tokio::test]
async fn enqueue_incoming_response_waits_instead_of_dropping_when_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1);
let (writer_tx, _writer_rx) = mpsc::channel(1);
let first_message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: first_message.clone(),
})
.await
.expect("queue should accept first message");
let response = JSONRPCMessage::Response(JSONRPCResponse {
id: RequestId::Integer(7),
result: json!({"ok": true}),
});
let transport_event_tx_for_enqueue = transport_event_tx.clone();
let writer_tx_for_enqueue = writer_tx.clone();
let enqueue_handle = tokio::spawn(async move {
enqueue_incoming_message(
&transport_event_tx_for_enqueue,
&writer_tx_for_enqueue,
connection_id,
response,
)
.await
});
let queued_event = transport_event_rx
.recv()
.await
.expect("first event should be dequeued");
match queued_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message,
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(message, first_message);
}
_ => panic!("expected queued incoming message"),
}
let enqueue_result = enqueue_handle.await.expect("enqueue task should not panic");
assert!(enqueue_result);
let forwarded_event = transport_event_rx
.recv()
.await
.expect("response should be forwarded instead of dropped");
match forwarded_event {
TransportEvent::IncomingMessage {
connection_id: queued_connection_id,
message: JSONRPCMessage::Response(JSONRPCResponse { id, result }),
} => {
assert_eq!(queued_connection_id, connection_id);
assert_eq!(id, RequestId::Integer(7));
assert_eq!(result, json!({"ok": true}));
}
_ => panic!("expected forwarded response message"),
}
}
#[tokio::test]
async fn enqueue_incoming_request_does_not_block_when_writer_queue_is_full() {
let connection_id = ConnectionId(42);
let (transport_event_tx, _transport_event_rx) = mpsc::channel(1);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message: JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
}),
})
.await
.expect("transport queue should accept first message");
writer_tx
.send(QueuedOutgoingMessage::new(
OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning(
ConfigWarningNotification {
summary: "queued".to_string(),
details: None,
path: None,
range: None,
},
)),
))
.await
.expect("writer queue should accept first message");
let request = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(7),
method: "config/read".to_string(),
params: Some(json!({ "includeLayers": false })),
trace: None,
});
let enqueue_result = timeout(
Duration::from_millis(100),
enqueue_incoming_message(&transport_event_tx, &writer_tx, connection_id, request),
)
.await
.expect("enqueue should not block while writer queue is full");
assert!(enqueue_result);
let queued_outgoing = writer_rx
.recv()
.await
.expect("writer queue should still contain original message");
let queued_json =
serde_json::to_value(queued_outgoing.message).expect("serialize queued message");
assert_eq!(
queued_json,
json!({
"method": "configWarning",
"params": {
"summary": "queued",
"details": null,
},
})
);
}
}
@@ -195,7 +195,7 @@ impl ClientTracker {
})
.await
}
ClientEvent::Ack => Ok(()),
ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()),
ClientEvent::Ping => {
if let Some(client) = self.clients.get_mut(&client_key) {
client.last_activity_at = Instant::now();
@@ -1,6 +1,7 @@
mod client_tracker;
mod enroll;
mod protocol;
mod segment;
mod websocket;
use crate::transport::remote_control::websocket::RemoteControlChannels;
@@ -35,14 +36,14 @@ pub(super) struct QueuedServerEnvelope {
}
#[derive(Clone)]
pub(crate) struct RemoteControlHandle {
pub struct RemoteControlHandle {
enabled_tx: Arc<watch::Sender<bool>>,
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
state_db_available: bool,
}
impl RemoteControlHandle {
pub(crate) fn set_enabled(&self, enabled: bool) {
pub fn set_enabled(&self, enabled: bool) {
let requested_enabled = enabled;
let enabled = enabled && self.state_db_available;
if requested_enabled && !self.state_db_available {
@@ -55,14 +56,12 @@ impl RemoteControlHandle {
});
}
pub(crate) fn status_receiver(
&self,
) -> watch::Receiver<RemoteControlStatusChangedNotification> {
pub fn status_receiver(&self) -> watch::Receiver<RemoteControlStatusChangedNotification> {
self.status_tx.subscribe()
}
}
pub(crate) async fn start_remote_control(
pub async fn start_remote_control(
remote_control_url: String,
state_db: Option<Arc<StateRuntime>>,
auth_manager: Arc<AuthManager>,
@@ -121,5 +120,7 @@ pub(crate) async fn start_remote_control(
))
}
#[cfg(test)]
mod segment_tests;
#[cfg(test)]
mod tests;
@@ -47,10 +47,20 @@ pub enum ClientEvent {
ClientMessage {
message: JSONRPCMessage,
},
ClientMessageChunk {
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
message_chunk_base64: String,
},
/// Backend-generated acknowledgement for all server envelopes addressed to
/// `client_id` and `stream_id` whose envelope `seq_id` is less than or equal
/// to this ack's `seq_id`. This cursor is stream-scoped.
Ack,
/// to this ack's `seq_id`. Chunk acknowledgements carry `segment_id` so the
/// sender can retain only the still-unacked wire chunks on reconnect.
Ack {
#[serde(skip_serializing_if = "Option::is_none")]
segment_id: Option<usize>,
},
Ping,
ClientClosed,
}
@@ -85,6 +95,12 @@ pub enum ServerEvent {
ServerMessage {
message: Box<OutgoingMessage>,
},
ServerMessageChunk {
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
message_chunk_base64: String,
},
#[allow(dead_code)]
Ack,
Pong {
@@ -92,6 +108,15 @@ pub enum ServerEvent {
},
}
impl ServerEvent {
pub(crate) fn segment_id(&self) -> Option<usize> {
match self {
Self::ServerMessageChunk { segment_id, .. } => Some(*segment_id),
Self::ServerMessage { .. } | Self::Ack | Self::Pong { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ServerEnvelope {
@@ -0,0 +1,449 @@
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::ServerEnvelope;
use super::protocol::ServerEvent;
use super::protocol::StreamId;
use base64::DecodeSliceError;
use base64::Engine;
use codex_app_server_protocol::JSONRPCMessage;
use std::collections::HashMap;
use std::io;
use std::io::ErrorKind;
use std::io::Write;
use tokio::time::Instant;
use tracing::warn;
pub(super) const REMOTE_CONTROL_SEGMENT_TARGET_BYTES: usize = 100 * 1024;
pub(super) const REMOTE_CONTROL_SEGMENT_MAX_BYTES: usize = 150 * 1024;
pub(super) const REMOTE_CONTROL_REASSEMBLED_MAX_BYTES: usize = 100 * 1024 * 1024;
pub(super) const REMOTE_CONTROL_SEGMENT_COUNT_MAX: usize = 1024;
const REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT: usize = 128;
#[derive(Debug)]
struct ClientSegmentAssembly {
stream_id: StreamId,
metadata: ClientSegmentMetadata,
raw: Vec<u8>,
next_segment_id: usize,
last_chunk_seen_at: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientSegmentMetadata {
seq_id: u64,
segment_count: usize,
message_size_bytes: usize,
}
#[derive(Default)]
pub(super) struct ClientSegmentReassembler {
assemblies: HashMap<ClientId, ClientSegmentAssembly>,
}
pub(super) enum ClientSegmentObservation {
Forward(Box<ClientEnvelope>),
Pending,
Dropped,
}
impl ClientSegmentReassembler {
pub(super) fn observe(&mut self, envelope: ClientEnvelope) -> ClientSegmentObservation {
let ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64,
} = &envelope.event
else {
return ClientSegmentObservation::Forward(Box::new(envelope));
};
let segment_id = *segment_id;
let segment_count = *segment_count;
let message_size_bytes = *message_size_bytes;
let Some(metadata) = ClientSegmentMetadata::from_envelope(&envelope) else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without seq_id"
);
return ClientSegmentObservation::Dropped;
};
let Some(stream_id) = envelope.stream_id.clone() else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without stream_id"
);
return ClientSegmentObservation::Dropped;
};
if self.should_ignore_chunk(&envelope.client_id, &stream_id, metadata.seq_id, segment_id) {
return ClientSegmentObservation::Dropped;
}
if segment_count == 0
|| segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX
|| segment_id >= segment_count
|| message_size_bytes == 0
|| message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES
|| message_chunk_base64.is_empty()
{
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping invalid segmented remote-control client envelope"
);
self.remove_assembly(&envelope.client_id, &stream_id);
return ClientSegmentObservation::Dropped;
}
let now = Instant::now();
match self.assemblies.get(&envelope.client_id) {
Some(assembly) if assembly.stream_id != stream_id => {
warn!(
client_id = envelope.client_id.0.as_str(),
"resetting segmented remote-control client envelope after stream change"
);
self.assemblies.insert(
envelope.client_id.clone(),
ClientSegmentAssembly {
stream_id: stream_id.clone(),
metadata: metadata.clone(),
raw: Vec::new(),
next_segment_id: 0,
last_chunk_seen_at: now,
},
);
}
Some(_) => {}
None => {
self.evict_assemblies_if_full();
self.assemblies.insert(
envelope.client_id.clone(),
ClientSegmentAssembly {
stream_id: stream_id.clone(),
metadata: metadata.clone(),
raw: Vec::new(),
next_segment_id: 0,
last_chunk_seen_at: now,
},
);
}
}
let result = {
let Some(assembly) = self.assemblies.get_mut(&envelope.client_id) else {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope without assembly"
);
return ClientSegmentObservation::Dropped;
};
if metadata.seq_id < assembly.metadata.seq_id {
AssemblyUpdate::Ignore
} else if assembly.metadata != metadata {
warn!(
client_id = envelope.client_id.0.as_str(),
"resetting segmented remote-control client envelope after metadata mismatch"
);
AssemblyUpdate::Drop
} else if segment_id < assembly.next_segment_id {
AssemblyUpdate::Pending
} else if segment_id != assembly.next_segment_id {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping out-of-order segmented remote-control client envelope"
);
AssemblyUpdate::Drop
} else {
assembly.last_chunk_seen_at = now;
let chunk_start = assembly.raw.len();
let decoded_chunk_len = base64::decoded_len_estimate(message_chunk_base64.len());
let chunk_end = usize::min(
message_size_bytes,
chunk_start.saturating_add(decoded_chunk_len),
);
assembly.raw.resize(chunk_end, 0);
match base64::engine::general_purpose::STANDARD.decode_slice(
message_chunk_base64.as_bytes(),
&mut assembly.raw[chunk_start..],
) {
Ok(decoded_chunk_len) => {
assembly.raw.truncate(chunk_start + decoded_chunk_len);
assembly.next_segment_id += 1;
if assembly.next_segment_id < segment_count {
AssemblyUpdate::Pending
} else if assembly.raw.len() != message_size_bytes {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping reassembled remote-control client envelope with mismatched size"
);
AssemblyUpdate::Drop
} else {
match serde_json::from_slice::<JSONRPCMessage>(&assembly.raw) {
Ok(message) => AssemblyUpdate::Complete(message),
Err(err) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping invalid reassembled remote-control client envelope: {err}"
);
AssemblyUpdate::Drop
}
}
}
}
Err(DecodeSliceError::OutputSliceTooSmall) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope after size overflow"
);
AssemblyUpdate::Drop
}
Err(err) => {
warn!(
client_id = envelope.client_id.0.as_str(),
"dropping segmented remote-control client envelope with invalid base64: {err}"
);
AssemblyUpdate::Drop
}
}
}
};
match result {
AssemblyUpdate::Pending => ClientSegmentObservation::Pending,
AssemblyUpdate::Ignore => ClientSegmentObservation::Dropped,
AssemblyUpdate::Drop => {
self.remove_assembly(&envelope.client_id, &stream_id);
ClientSegmentObservation::Dropped
}
AssemblyUpdate::Complete(message) => {
self.remove_assembly(&envelope.client_id, &stream_id);
ClientSegmentObservation::Forward(Box::new(ClientEnvelope {
event: ClientEvent::ClientMessage { message },
..envelope
}))
}
}
}
pub(super) fn invalidate_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) {
self.remove_assembly(client_id, stream_id);
}
pub(super) fn invalidate_client(&mut self, client_id: &ClientId) {
self.assemblies.remove(client_id);
}
pub(super) fn should_ignore_chunk(
&self,
client_id: &ClientId,
stream_id: &StreamId,
seq_id: u64,
segment_id: usize,
) -> bool {
self.assemblies.get(client_id).is_some_and(|assembly| {
assembly.stream_id == *stream_id
&& (seq_id < assembly.metadata.seq_id
|| (seq_id == assembly.metadata.seq_id
&& segment_id < assembly.next_segment_id))
})
}
fn remove_assembly(&mut self, client_id: &ClientId, stream_id: &StreamId) {
if self
.assemblies
.get(client_id)
.is_some_and(|assembly| &assembly.stream_id == stream_id)
{
self.assemblies.remove(client_id);
}
}
fn evict_assemblies_if_full(&mut self) {
while self.assemblies.len() >= REMOTE_CONTROL_SEGMENT_ASSEMBLY_MAX_COUNT {
let Some(client_id) = self
.assemblies
.iter()
.min_by_key(|(_, assembly)| assembly.last_chunk_seen_at)
.map(|(client_id, _)| client_id.clone())
else {
return;
};
self.assemblies.remove(&client_id);
}
}
}
enum AssemblyUpdate {
Pending,
Ignore,
Drop,
Complete(JSONRPCMessage),
}
impl ClientSegmentMetadata {
fn from_envelope(envelope: &ClientEnvelope) -> Option<Self> {
let ClientEvent::ClientMessageChunk {
segment_count,
message_size_bytes,
..
} = &envelope.event
else {
return None;
};
Some(Self {
seq_id: envelope.seq_id?,
segment_count: *segment_count,
message_size_bytes: *message_size_bytes,
})
}
}
pub(super) fn split_server_envelope_for_transport(
envelope: ServerEnvelope,
) -> io::Result<Vec<ServerEnvelope>> {
if !matches!(envelope.event, ServerEvent::ServerMessage { .. }) {
return Ok(vec![envelope]);
}
let envelope_size_bytes = serialized_len(&envelope)?;
if envelope_size_bytes <= REMOTE_CONTROL_SEGMENT_MAX_BYTES {
return Ok(vec![envelope]);
}
let ServerEvent::ServerMessage { message } = envelope.event.clone() else {
unreachable!("server message variant checked above");
};
let raw = serde_json::to_vec(message.as_ref()).map_err(io::Error::other)?;
let message_size_bytes = raw.len();
if message_size_bytes > REMOTE_CONTROL_REASSEMBLED_MAX_BYTES {
warn!("dropping remote-control server envelope that exceeds reassembled size limit");
return Ok(Vec::new());
}
let minimal_segment_count =
usize::min(message_size_bytes.max(1), REMOTE_CONTROL_SEGMENT_COUNT_MAX);
let minimal_chunk = &raw[..usize::min(raw.len(), 1)];
if serialized_chunk_len(
&envelope,
/*segment_id*/ 0,
minimal_segment_count,
message_size_bytes,
minimal_chunk,
)? > REMOTE_CONTROL_SEGMENT_MAX_BYTES
{
warn!("dropping remote-control server envelope that cannot fit within segment size limit");
return Ok(Vec::new());
}
let mut segment_count = usize::max(
2,
message_size_bytes.div_ceil(REMOTE_CONTROL_SEGMENT_TARGET_BYTES),
);
loop {
let chunk_size = usize::max(1, message_size_bytes.div_ceil(segment_count));
segment_count = message_size_bytes.div_ceil(chunk_size);
let segments_fit = raw
.chunks(chunk_size)
.enumerate()
.all(|(segment_id, chunk)| {
serialized_chunk_len(
&envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)
.is_ok_and(|size| size <= REMOTE_CONTROL_SEGMENT_MAX_BYTES)
});
if segments_fit {
return raw
.chunks(chunk_size)
.enumerate()
.map(|(segment_id, chunk)| {
build_chunk_envelope(
&envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)
})
.collect();
}
if chunk_size == 1 {
warn!(
"dropping remote-control server envelope that cannot fit within segment size limit"
);
return Ok(Vec::new());
}
let next_segment_count = segment_count + 1;
let next_chunk_size = usize::max(1, message_size_bytes.div_ceil(next_segment_count));
segment_count = if next_chunk_size == chunk_size {
message_size_bytes
} else {
next_segment_count
};
}
}
fn serialized_chunk_len(
envelope: &ServerEnvelope,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> io::Result<usize> {
serialized_len(&build_chunk_envelope(
envelope,
segment_id,
segment_count,
message_size_bytes,
chunk,
)?)
}
#[derive(Default)]
struct CountingWriter {
len: usize,
}
impl Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.len += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn serialized_len(value: &impl serde::Serialize) -> io::Result<usize> {
let mut writer = CountingWriter::default();
serde_json::to_writer(&mut writer, value).map_err(io::Error::other)?;
Ok(writer.len)
}
fn build_chunk_envelope(
envelope: &ServerEnvelope,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> io::Result<ServerEnvelope> {
if segment_count > REMOTE_CONTROL_SEGMENT_COUNT_MAX {
return Err(io::Error::new(
ErrorKind::InvalidData,
"remote-control segment count exceeds maximum",
));
}
Ok(ServerEnvelope {
event: ServerEvent::ServerMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id: envelope.client_id.clone(),
stream_id: envelope.stream_id.clone(),
seq_id: envelope.seq_id,
})
}
@@ -0,0 +1,386 @@
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::ServerEnvelope;
use super::protocol::ServerEvent;
use super::protocol::StreamId;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use crate::outgoing_message::OutgoingMessage;
use base64::Engine;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::ServerNotification;
use pretty_assertions::assert_eq;
#[test]
fn reassembles_client_message_chunks() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
let reassembled = match reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id,
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)) {
ClientSegmentObservation::Forward(reassembled) => *reassembled,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => {
panic!("message should reassemble")
}
};
assert_eq!(reassembled.client_id, client_id);
assert_eq!(
reassembled.stream_id,
Some(StreamId("stream-1".to_string()))
);
assert_eq!(reassembled.seq_id, Some(7));
assert_eq!(reassembled.cursor, None);
match reassembled.event {
ClientEvent::ClientMessage {
message: reassembled_message,
} => assert_eq!(reassembled_message, message),
other => panic!("expected client message, got {other:?}"),
}
}
#[test]
fn splits_large_server_messages_into_wire_chunks() {
let envelope = ServerEnvelope {
event: ServerEvent::ServerMessage {
message: Box::new(OutgoingMessage::AppServerNotification(
ServerNotification::ConfigWarning(ConfigWarningNotification {
summary: "x".repeat(REMOTE_CONTROL_SEGMENT_MAX_BYTES),
details: None,
path: None,
range: None,
}),
)),
},
client_id: ClientId("client-1".to_string()),
stream_id: StreamId("stream-1".to_string()),
seq_id: 9,
};
let segments = split_server_envelope_for_transport(envelope).expect("split should succeed");
assert!(segments.len() > 1);
assert!(
segments
.iter()
.all(|segment| matches!(segment.event, ServerEvent::ServerMessageChunk { .. }))
);
assert!(segments.iter().all(|segment| segment.seq_id == 9));
assert!(segments.iter().all(|segment| {
serde_json::to_vec(segment)
.expect("segment should serialize")
.len()
<= REMOTE_CONTROL_SEGMENT_MAX_BYTES
}));
}
#[test]
fn invalidates_incomplete_stream_assemblies() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(stream_id.clone()),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
reassembler.invalidate_stream(&client_id, &stream_id);
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
Some(stream_id),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Dropped
));
}
#[test]
fn resets_incomplete_client_assembly_when_stream_changes() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let first_stream_id = StreamId("stream-1".to_string());
let second_stream_id = StreamId("stream-2".to_string());
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(first_stream_id.clone()),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
Some(second_stream_id.clone()),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
let reassembled = match reassembler.observe(chunk_envelope(
client_id.clone(),
Some(second_stream_id),
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)) {
ClientSegmentObservation::Forward(reassembled) => *reassembled,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => {
panic!("replacement stream should reassemble")
}
};
assert_eq!(
reassembled.stream_id,
Some(StreamId("stream-2".to_string()))
);
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
Some(first_stream_id),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Dropped
));
}
#[test]
fn ignores_stale_chunks_without_dropping_newer_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn ignores_invalid_stale_chunks_without_dropping_newer_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 7,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
b"",
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn ignores_invalid_duplicate_chunks_without_dropping_current_assembly() {
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let client_id = ClientId("client-1".to_string());
let stream_id = Some(StreamId("stream-1".to_string()));
let mut reassembler = ClientSegmentReassembler::default();
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
)),
ClientSegmentObservation::Pending
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id.clone(),
stream_id.clone(),
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
b"",
)),
ClientSegmentObservation::Dropped
));
assert!(matches!(
reassembler.observe(chunk_envelope(
client_id,
stream_id,
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
)),
ClientSegmentObservation::Forward(_)
));
}
fn chunk_envelope(
client_id: ClientId,
stream_id: Option<StreamId>,
seq_id: u64,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> ClientEnvelope {
ClientEnvelope {
event: ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id,
stream_id,
seq_id: Some(seq_id),
cursor: None,
}
}
@@ -831,7 +831,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
send_client_event(
&mut first_websocket,
ClientEnvelope {
event: ClientEvent::Ack,
event: ClientEvent::Ack { segment_id: None },
client_id: client_id.clone(),
stream_id: Some(stream_id),
seq_id: Some(1),
@@ -15,6 +15,10 @@ use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
use super::segment::split_server_envelope_for_transport;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
@@ -49,7 +53,7 @@ use tracing::error;
use tracing::info;
use tracing::warn;
pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "2";
pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3";
pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor";
const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration =
@@ -85,17 +89,29 @@ impl BoundedOutboundBuffer {
self.used_tx.send_modify(|used| *used += 1);
}
fn ack(&mut self, client_id: &ClientId, stream_id: &StreamId, acked_seq_id: u64) {
fn ack(
&mut self,
client_id: &ClientId,
stream_id: &StreamId,
acked_seq_id: u64,
acked_segment_id: Option<usize>,
) {
let key = (client_id.clone(), stream_id.clone());
let Some(buffer) = self.buffer_by_stream.get_mut(&key) else {
return;
};
while let Some(server_envelope) = buffer.front()
&& server_envelope.seq_id <= acked_seq_id
{
buffer.pop_front();
self.used_tx.send_modify(|used| *used -= 1);
}
let acked_cursor = (acked_seq_id, acked_segment_id.unwrap_or(usize::MAX));
buffer.retain(|server_envelope| {
let envelope_cursor = (
server_envelope.seq_id,
server_envelope.event.segment_id().unwrap_or_default(),
);
let is_acked = envelope_cursor <= acked_cursor;
if is_acked {
self.used_tx.send_modify(|used| *used -= 1);
}
!is_acked
});
if buffer.is_empty() {
self.buffer_by_stream.remove(&key);
}
@@ -112,6 +128,88 @@ struct WebsocketState {
outbound_buffer: BoundedOutboundBuffer,
subscribe_cursor: Option<String>,
next_seq_id_by_stream: HashMap<(ClientId, StreamId), u64>,
last_completed_client_chunk_seq_id_by_stream: HashMap<(ClientId, Option<StreamId>), u64>,
client_segment_reassembler: ClientSegmentReassembler,
}
impl WebsocketState {
fn observe_client_message(
&mut self,
client_envelope: ClientEnvelope,
wire_size_bytes: usize,
) -> ClientSegmentObservation {
let client_message_key = Self::client_message_key(&client_envelope);
if let Some((key, seq_id)) = client_message_key.as_ref()
&& self
.last_completed_client_chunk_seq_id_by_stream
.get(key)
.is_some_and(|last_seq_id| last_seq_id >= seq_id)
{
return ClientSegmentObservation::Dropped;
}
if let (
Some((_, seq_id)),
Some(stream_id),
ClientEvent::ClientMessageChunk { segment_id, .. },
) = (
client_message_key.as_ref(),
client_envelope.stream_id.as_ref(),
&client_envelope.event,
) && self.client_segment_reassembler.should_ignore_chunk(
&client_envelope.client_id,
stream_id,
*seq_id,
*segment_id,
) {
return ClientSegmentObservation::Dropped;
}
if client_message_key.is_some() && wire_size_bytes > REMOTE_CONTROL_SEGMENT_MAX_BYTES {
warn!(
client_id = client_envelope.client_id.0.as_str(),
"dropping oversized segmented remote-control client envelope"
);
if let Some(stream_id) = client_envelope.stream_id.as_ref() {
self.client_segment_reassembler
.invalidate_stream(&client_envelope.client_id, stream_id);
}
return ClientSegmentObservation::Dropped;
}
let observation = self.client_segment_reassembler.observe(client_envelope);
if matches!(observation, ClientSegmentObservation::Forward(_))
&& let Some((key, seq_id)) = client_message_key
{
self.last_completed_client_chunk_seq_id_by_stream
.insert(key, seq_id);
}
observation
}
fn invalidate_client_message_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) {
self.last_completed_client_chunk_seq_id_by_stream
.remove(&(client_id.clone(), Some(stream_id.clone())));
}
fn invalidate_client_message_client(&mut self, client_id: &ClientId) {
self.last_completed_client_chunk_seq_id_by_stream
.retain(|(cursor_client_id, _), _| cursor_client_id != client_id);
}
fn client_message_key(
client_envelope: &ClientEnvelope,
) -> Option<((ClientId, Option<StreamId>), u64)> {
let seq_id = match (&client_envelope.event, client_envelope.seq_id) {
(ClientEvent::ClientMessageChunk { .. }, Some(seq_id)) => seq_id,
_ => return None,
};
Some((
(
client_envelope.client_id.clone(),
client_envelope.stream_id.clone(),
),
seq_id,
))
}
}
pub(crate) struct RemoteControlWebsocket {
@@ -231,6 +329,8 @@ impl RemoteControlWebsocket {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
})),
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
used_rx,
@@ -556,7 +656,7 @@ impl RemoteControlWebsocket {
}
}
};
let (payload, write_complete_tx) = {
let (payloads, write_complete_tx) = {
let mut state = state.lock().await;
let seq_key = (
queued_server_envelope.client_id.clone(),
@@ -573,29 +673,42 @@ impl RemoteControlWebsocket {
seq_id,
stream_id: queued_server_envelope.stream_id,
};
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
let server_envelopes = match split_server_envelope_for_transport(server_envelope) {
Ok(server_envelopes) => server_envelopes,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
error!("failed to split remote-control server event: {err}");
continue;
}
};
let mut payloads = Vec::with_capacity(server_envelopes.len());
for server_envelope in server_envelopes {
let payload = match serde_json::to_string(&server_envelope) {
Ok(payload) => payload,
Err(err) => {
error!("failed to serialize remote-control server event: {err}");
continue;
}
};
state.outbound_buffer.insert(&server_envelope);
payloads.push(payload);
}
state
.next_seq_id_by_stream
.insert(seq_key, seq_id.saturating_add(1));
state.outbound_buffer.insert(&server_envelope);
(payload, queued_server_envelope.write_complete_tx)
(payloads, queued_server_envelope.write_complete_tx)
};
tokio::select! {
_ = shutdown_token.cancelled() => return Ok(()),
send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => {
if let Err(err) = send_result {
return Err(io::Error::other(err));
for payload in payloads {
tokio::select! {
_ = shutdown_token.cancelled() => return Ok(()),
send_result = websocket_writer.send(tungstenite::Message::Text(payload.into())) => {
if let Err(err) = send_result {
return Err(io::Error::other(err));
}
}
}
};
}
if let Some(write_complete_tx) = write_complete_tx {
let _ = write_complete_tx.send(());
}
@@ -657,11 +770,30 @@ impl RemoteControlWebsocket {
if client_tracker.close_client(&client_key).await.is_err() {
return Ok(());
}
state
.lock()
.await
.client_segment_reassembler
.invalidate_stream(&client_key.0, &client_key.1);
state
.lock()
.await
.invalidate_client_message_stream(&client_key.0, &client_key.1);
continue;
}
_ = idle_sweep_interval.tick() => {
if client_tracker.close_expired_clients().await.is_err() {
return Ok(());
match client_tracker.close_expired_clients().await {
Ok(client_keys) => {
let mut websocket_state = state.lock().await;
for (client_id, stream_id) in client_keys {
websocket_state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
websocket_state
.invalidate_client_message_stream(&client_id, &stream_id);
}
}
Err(_) => return Ok(()),
}
continue;
}
@@ -672,10 +804,11 @@ impl RemoteControlWebsocket {
}
}
};
let client_envelope = match incoming_message {
let (client_envelope, wire_size_bytes) = match incoming_message {
Ok(tungstenite::Message::Text(text)) => {
let wire_size_bytes = text.len();
match serde_json::from_str::<ClientEnvelope>(&text) {
Ok(client_envelope) => client_envelope,
Ok(client_envelope) => (client_envelope, wire_size_bytes),
Err(err) => {
warn!("failed to deserialize remote-control client event: {err}");
continue;
@@ -707,12 +840,21 @@ impl RemoteControlWebsocket {
}
};
let observation = {
let mut websocket_state = state.lock().await;
websocket_state.observe_client_message(client_envelope, wire_size_bytes)
};
let client_envelope = match observation {
ClientSegmentObservation::Forward(client_envelope) => *client_envelope,
ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => continue,
};
{
let mut websocket_state = state.lock().await;
if let Some(cursor) = client_envelope.cursor.as_deref() {
websocket_state.subscribe_cursor = Some(cursor.to_string());
}
if let ClientEvent::Ack = &client_envelope.event
if let ClientEvent::Ack { segment_id } = &client_envelope.event
&& let Some(acked_seq_id) = client_envelope.seq_id
&& let Some(stream_id) = client_envelope.stream_id.as_ref()
{
@@ -720,10 +862,18 @@ impl RemoteControlWebsocket {
&client_envelope.client_id,
stream_id,
acked_seq_id,
*segment_id,
);
}
}
let closed_client =
matches!(&client_envelope.event, ClientEvent::ClientClosed).then(|| {
(
client_envelope.client_id.clone(),
client_envelope.stream_id.clone(),
)
});
if client_tracker
.handle_message(client_envelope)
.await
@@ -731,6 +881,20 @@ impl RemoteControlWebsocket {
{
return Ok(());
}
if let Some((client_id, stream_id)) = closed_client {
let mut websocket_state = state.lock().await;
if let Some(stream_id) = stream_id {
websocket_state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
websocket_state.invalidate_client_message_stream(&client_id, &stream_id);
} else {
websocket_state
.client_segment_reassembler
.invalidate_client(&client_id);
websocket_state.invalidate_client_message_client(&client_id);
}
}
}
}
}
@@ -1052,6 +1216,8 @@ mod tests {
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::test_support::auth_manager_from_auth;
@@ -1603,6 +1769,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (_server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
@@ -1639,6 +1807,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (server_event_tx, server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let server_event_rx = Arc::new(Mutex::new(server_event_rx));
@@ -1716,6 +1886,8 @@ mod tests {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
}));
let (server_event_tx, _server_event_rx) = mpsc::channel(super::super::CHANNEL_CAPACITY);
let (transport_event_tx, _transport_event_rx) =
@@ -1771,7 +1943,9 @@ mod tests {
"first-client-new-stream",
));
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 3);
outbound_buffer.ack(
&client_1, &stream_1, /*acked_seq_id*/ 3, /*acked_segment_id*/ None,
);
let mut retained = outbound_buffer
.server_envelopes()
@@ -1814,7 +1988,9 @@ mod tests {
&client_2, "stream-1", /*seq_id*/ 3, "second",
));
outbound_buffer.ack(&client_1, &stream_1, /*acked_seq_id*/ 1);
outbound_buffer.ack(
&client_1, &stream_1, /*acked_seq_id*/ 1, /*acked_segment_id*/ None,
);
let mut retained = outbound_buffer
.server_envelopes()
@@ -1834,6 +2010,390 @@ mod tests {
assert_eq!(*used_rx.borrow(), 2);
}
#[test]
fn outbound_buffer_advances_segmented_acks_by_wire_cursor() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
));
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
));
outbound_buffer.ack(
&client_id,
&stream_id,
/*acked_seq_id*/ 4,
/*acked_segment_id*/ Some(1),
);
let retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| server_envelope.event.segment_id())
.collect::<Vec<_>>();
assert_eq!(retained, Vec::<Option<usize>>::new());
assert_eq!(*used_rx.borrow(), 0);
}
#[test]
fn outbound_buffer_treats_segmentless_acks_as_seq_level_acks() {
let (mut outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
));
outbound_buffer.insert(&server_chunk_envelope(
&client_id, "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
));
outbound_buffer.ack(
&client_id, &stream_id, /*acked_seq_id*/ 4, /*acked_segment_id*/ None,
);
let retained = outbound_buffer
.server_envelopes()
.map(|server_envelope| server_envelope.event.segment_id())
.collect::<Vec<_>>();
assert_eq!(retained, Vec::<Option<usize>>::new());
assert_eq!(*used_rx.borrow(), 0);
}
#[test]
fn websocket_state_drops_duplicate_client_chunks_while_pending() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"y",
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_drops_replayed_client_chunks_after_completion() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 4,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 4,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Forward(_)
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Dropped
));
}
#[test]
fn websocket_state_allows_replay_after_rejected_out_of_order_chunk() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"y",
);
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_allows_replay_after_later_chunk_drops() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let first_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
);
let invalid_second_chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 1,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"",
);
assert!(matches!(
observe_client_message(&mut state, first_chunk.clone()),
ClientSegmentObservation::Pending
));
assert!(matches!(
observe_client_message(&mut state, invalid_second_chunk),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
}
#[test]
fn websocket_state_drops_oversized_client_chunk_frames() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let chunk = client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 1, /*message_size_bytes*/ 1, b"x",
);
assert!(matches!(
state.observe_client_message(chunk, REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1),
ClientSegmentObservation::Dropped
));
}
#[test]
fn websocket_state_ignores_oversized_stale_chunks_without_dropping_newer_assembly() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_newer_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let oversized_stale_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 7,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_newer_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_newer_chunk),
ClientSegmentObservation::Pending
));
assert!(matches!(
state.observe_client_message(
oversized_stale_chunk,
REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1,
),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_newer_chunk),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn websocket_state_ignores_oversized_duplicate_chunks_without_dropping_current_assembly() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let message = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
let raw = serde_json::to_vec(&message).expect("message should serialize");
let split = raw.len() / 2;
let first_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let oversized_duplicate_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 0,
/*segment_count*/ 2,
raw.len(),
&raw[..split],
);
let second_chunk = client_chunk_envelope(
"client-1",
"stream-1",
/*seq_id*/ 8,
/*segment_id*/ 1,
/*segment_count*/ 2,
raw.len(),
&raw[split..],
);
assert!(matches!(
observe_client_message(&mut state, first_chunk),
ClientSegmentObservation::Pending
));
assert!(matches!(
state.observe_client_message(
oversized_duplicate_chunk,
REMOTE_CONTROL_SEGMENT_MAX_BYTES + 1,
),
ClientSegmentObservation::Dropped
));
assert!(matches!(
observe_client_message(&mut state, second_chunk),
ClientSegmentObservation::Forward(_)
));
}
#[test]
fn websocket_state_clears_chunk_cursor_when_stream_is_invalidated() {
let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new();
let mut state = WebsocketState {
outbound_buffer,
subscribe_cursor: None,
next_seq_id_by_stream: HashMap::new(),
last_completed_client_chunk_seq_id_by_stream: HashMap::new(),
client_segment_reassembler: ClientSegmentReassembler::default(),
};
let client_id = ClientId("client-1".to_string());
let stream_id = StreamId("stream-1".to_string());
assert!(matches!(
observe_client_message(
&mut state,
client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 4, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
)
),
ClientSegmentObservation::Pending
));
state.invalidate_client_message_stream(&client_id, &stream_id);
state
.client_segment_reassembler
.invalidate_stream(&client_id, &stream_id);
assert!(matches!(
observe_client_message(
&mut state,
client_chunk_envelope(
"client-1", "stream-1", /*seq_id*/ 1, /*segment_id*/ 0,
/*segment_count*/ 2, /*message_size_bytes*/ 2, b"x",
)
),
ClientSegmentObservation::Pending
));
}
fn server_envelope(
client_id: &ClientId,
stream_id: &str,
@@ -1857,6 +2417,58 @@ mod tests {
}
}
fn server_chunk_envelope(
client_id: &ClientId,
stream_id: &str,
seq_id: u64,
segment_id: usize,
) -> ServerEnvelope {
ServerEnvelope {
event: ServerEvent::ServerMessageChunk {
segment_id,
segment_count: 2,
message_size_bytes: 2,
message_chunk_base64: String::new(),
},
client_id: client_id.clone(),
stream_id: StreamId(stream_id.to_string()),
seq_id,
}
}
fn client_chunk_envelope(
client_id: &str,
stream_id: &str,
seq_id: u64,
segment_id: usize,
segment_count: usize,
message_size_bytes: usize,
chunk: &[u8],
) -> ClientEnvelope {
ClientEnvelope {
event: ClientEvent::ClientMessageChunk {
segment_id,
segment_count,
message_size_bytes,
message_chunk_base64: base64::engine::general_purpose::STANDARD.encode(chunk),
},
client_id: ClientId(client_id.to_string()),
stream_id: Some(StreamId(stream_id.to_string())),
seq_id: Some(seq_id),
cursor: None,
}
}
fn observe_client_message(
state: &mut WebsocketState,
envelope: ClientEnvelope,
) -> ClientSegmentObservation {
let wire_size_bytes = serde_json::to_vec(&envelope)
.expect("client envelope should serialize")
.len();
state.observe_client_message(envelope, wire_size_bytes)
}
async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
@@ -21,7 +21,7 @@ use tracing::debug;
use tracing::error;
use tracing::info;
pub(crate) async fn start_stdio_connection(
pub async fn start_stdio_connection(
transport_event_tx: mpsc::Sender<TransportEvent>,
stdio_handles: &mut Vec<JoinHandle<()>>,
initialize_client_name_tx: oneshot::Sender<String>,
@@ -20,7 +20,7 @@ use tracing::warn;
#[cfg(unix)]
const CONTROL_SOCKET_MODE: u32 = 0o600;
pub(crate) async fn start_control_socket_acceptor(
pub async fn start_control_socket_acceptor(
socket_path: AbsolutePathBuf,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
@@ -128,7 +128,7 @@ async fn websocket_upgrade_handler(
.into_response()
}
pub(crate) async fn start_websocket_acceptor(
pub async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
+2 -9
View File
@@ -30,7 +30,6 @@ axum = { workspace = true, default-features = false, features = [
"ws",
] }
codex-analytics = { workspace = true }
codex-api = { workspace = true }
codex-arg0 = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
@@ -58,6 +57,7 @@ codex-model-provider = { workspace = true }
codex-models-manager = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-transport = { workspace = true }
codex-feedback = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-rollout = { workspace = true }
@@ -65,18 +65,11 @@ codex-sandboxing = { workspace = true }
codex-state = { workspace = true }
codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
@@ -93,7 +86,6 @@ tokio = { workspace = true, features = [
"signal",
] }
tokio-util = { workspace = true }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
url = { workspace = true }
@@ -111,6 +103,7 @@ core_test_support = { workspace = true }
codex-model-provider-info = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
flate2 = { workspace = true }
hmac = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
+93 -10
View File
@@ -144,12 +144,12 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Pass `excludeTurns: true` when the client plans to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/turns/list` — page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
@@ -181,6 +181,12 @@ Example with notification opt-out:
- `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`.
- `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`.
- `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session.
- `process/spawn` — experimental; spawn a standalone process without the Codex sandbox on the host where the app server is running; returns after the process starts and emits `process/outputDelta` and `process/exited` notifications.
- `process/writeStdin` — experimental; write base64-decoded stdin bytes to a running `process/spawn` session or close stdin; returns `{}`.
- `process/resizePty` — experimental; resize a running PTY-backed `process/spawn` session by `processHandle`; returns `{}`.
- `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`.
- `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session.
- `process/exited` — experimental; notification emitted when a `process/spawn` session exits.
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`.
- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`.
@@ -201,8 +207,9 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
@@ -276,7 +283,7 @@ Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`.
By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if you 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.
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.
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.
@@ -304,9 +311,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { … } } }
```
Like `thread/resume`, `thread/fork` also accepts `excludeTurns: true` to return only thread metadata in `thread.turns` and let the client page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage.
Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously.
Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage.
### Example: List threads (with pagination & filters)
@@ -402,7 +407,7 @@ Later, after the idle unload timeout:
### Example: Read a thread
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
```json
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }
@@ -418,9 +423,9 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl
} }
```
### Example: List thread turns
### Example: List thread turns (experimental)
Use `thread/turns/list` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page.
```json
{ "method": "thread/turns/list", "id": 24, "params": {
@@ -931,6 +936,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin
} }
```
- Prefer using `process/spawn` when you want an explicitly unsandboxed process execution API with immediate spawn acknowledgement, handle-based control, output notifications, and an exit notification.
- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`.
Notes:
@@ -1002,6 +1008,83 @@ Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes:
- `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable.
- `command/exec/resize` is only supported for PTY-backed `command/exec` sessions.
### Example: Process lifecycle execution
Use `process/spawn` to start a standalone argv-based process without the Codex sandbox on the host where the app server is running. The `process/*` API is experimental and requires `initialize.params.capabilities.experimentalApi: true`. The spawn response means the process has started and the `processHandle` is registered; completion is reported later through `process/exited`.
```json
{ "method": "process/spawn", "id": 40, "params": {
"command": ["cargo", "check"],
"processHandle": "cargo-check-1",
"cwd": "/Users/me/project", // required absolute path
"env": { "RUST_LOG": null }, // optional; override or unset app-server env vars
"outputBytesCap": 1048576, // optional; omit for default, null disables
"timeoutMs": 10000 // optional; omit for default, null disables
} }
{ "id": 40, "result": {} }
{ "method": "process/exited", "params": {
"processHandle": "cargo-check-1",
"exitCode": 0,
"stdout": "...",
"stdoutCapReached": false,
"stderr": "",
"stderrCapReached": false
} }
```
For interactive or streaming processes, set `tty: true` or `streamStdoutStderr: true` and route output notifications by `processHandle`:
```json
{ "method": "process/spawn", "id": 41, "params": {
"command": ["bash", "-i"],
"processHandle": "bash-1",
"cwd": "/Users/me/project",
"tty": true,
"size": { "rows": 40, "cols": 120 },
"outputBytesCap": null,
"timeoutMs": null
} }
{ "id": 41, "result": {} }
{ "method": "process/outputDelta", "params": {
"processHandle": "bash-1",
"stream": "stdout",
"deltaBase64": "YmFzaC00LjQkIA==",
"capReached": false
} }
{ "method": "process/writeStdin", "id": 42, "params": {
"processHandle": "bash-1",
"deltaBase64": "cHdkCg=="
} }
{ "id": 42, "result": {} }
{ "method": "process/resizePty", "id": 43, "params": {
"processHandle": "bash-1",
"size": { "rows": 48, "cols": 160 }
} }
{ "id": 43, "result": {} }
{ "method": "process/kill", "id": 44, "params": {
"processHandle": "bash-1"
} }
{ "id": 44, "result": {} }
{ "method": "process/exited", "params": {
"processHandle": "bash-1",
"exitCode": 137,
"stdout": "",
"stdoutCapReached": false,
"stderr": "",
"stderrCapReached": false
} }
```
- Empty `command` arrays and empty `processHandle` strings are rejected.
- `cwd` is required and must be absolute.
- `process/spawn` is intentionally unsandboxed and does not define sandbox-selection fields such as `sandboxPolicy` or `permissionProfile`.
- Duplicate active `processHandle` values are rejected on the same connection; the same handle can be reused after the prior process exits.
- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`.
- `process/writeStdin` accepts either `deltaBase64`, `closeStdin`, or both.
- When omitted, `timeoutMs` and `outputBytesCap` fall back to server defaults. Set either field to `null` to disable that limit for terminal-style sessions.
- `outputBytesCap` applies independently to `stdout` and `stderr`; `process/exited.stdoutCapReached` and `stderrCapReached` report whether each stream reached the cap. Streamed bytes are not duplicated into `process/exited`.
- `process/outputDelta` and `process/exited` notifications are connection-scoped. If the originating connection closes, the server terminates the process.
### Example: Filesystem utilities
These methods operate on absolute paths on the host filesystem and cover reading, writing, directory traversal, copying, removal, and change notifications.
+158 -223
View File
@@ -1,10 +1,9 @@
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::codex_message_processor::read_summary_from_rollout;
use crate::codex_message_processor::summary_to_thread;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::request_processors::populate_thread_turns_from_history;
use crate::request_processors::thread_from_stored_thread;
use crate::server_request_error::is_turn_transition_server_request_error;
use crate::thread_state::ThreadState;
use crate::thread_state::TurnSummary;
@@ -29,7 +28,6 @@ use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::GrantedPermissionProfile as V2GrantedPermissionProfile;
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::HookCompletedNotification;
@@ -46,7 +44,6 @@ use codex_app_server_protocol::ModelVerificationNotification;
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PermissionsRequestApprovalParams;
use codex_app_server_protocol::PermissionsRequestApprovalResponse;
use codex_app_server_protocol::RawResponseItemCompletedNotification;
@@ -66,6 +63,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification;
use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::ToolRequestUserInputOption;
@@ -82,16 +80,11 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WarningNotification;
use codex_app_server_protocol::build_file_change_approval_request_item;
use codex_app_server_protocol::build_file_change_end_item;
use codex_app_server_protocol::build_item_from_guardian_event;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_app_server_protocol::convert_patch_changes;
use codex_app_server_protocol::guardian_auto_approval_review_notification;
use codex_app_server_protocol::item_event_to_server_notification;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::find_thread_name_by_id;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
@@ -119,12 +112,12 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use tracing::warn;
enum CommandExecutionApprovalPresentation {
Network(V2NetworkApprovalContext),
@@ -150,7 +143,6 @@ pub(crate) async fn apply_bespoke_event_handling(
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<tokio::sync::Semaphore>,
fallback_model_provider: String,
codex_home: &Path,
) {
let Event {
id: event_turn_id,
@@ -524,28 +516,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let permission_guard = thread_watch_manager
.note_permission_requested(&conversation_id.to_string())
.await;
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = event.call_id.clone();
let patch_changes = convert_patch_changes(&event.changes);
let first_start = {
let mut state = thread_state.lock().await;
state
.turn_summary
.file_change_started
.insert(item_id.clone())
};
if first_start {
let item = build_file_change_approval_request_item(&event);
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.await;
}
let params = FileChangeRequestApprovalParams {
thread_id: conversation_id.to_string(),
@@ -559,14 +530,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
tokio::spawn(async move {
on_file_change_request_approval_response(
event_turn_id,
conversation_id,
item_id,
patch_changes,
pending_request_id,
rx,
conversation,
outgoing,
thread_state.clone(),
permission_guard,
)
@@ -846,6 +813,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
started_at_ms: request.started_at_ms,
item,
};
outgoing
@@ -866,9 +834,11 @@ pub(crate) async fn apply_bespoke_event_handling(
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for legacy clients.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::DynamicToolCallResponse(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
@@ -984,28 +954,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}))
.await;
}
EventMsg::ViewImageToolCall(view_image_event) => {
let item = ThreadItem::ImageView {
id: view_image_event.call_id.clone(),
path: view_image_event.path.clone(),
};
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item: item.clone(),
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(started))
.await;
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::ViewImageToolCall(_) => {}
EventMsg::EnteredReviewMode(review_request) => {
let review = review_request
.user_facing_hint
@@ -1017,6 +966,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
started_at_ms: now_unix_timestamp_ms(),
item: item.clone(),
};
outgoing
@@ -1025,6 +975,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
item,
};
outgoing
@@ -1074,6 +1025,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let started = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
started_at_ms: now_unix_timestamp_ms(),
item: item.clone(),
};
outgoing
@@ -1082,6 +1034,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let completed = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
completed_at_ms: now_unix_timestamp_ms(),
item,
};
outgoing
@@ -1104,40 +1057,9 @@ pub(crate) async fn apply_bespoke_event_handling(
)
.await;
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = patch_begin_event.call_id.clone();
let first_start = {
let mut state = thread_state.lock().await;
state
.turn_summary
.file_change_started
.insert(item_id.clone())
};
if first_start {
let notification = item_event_to_server_notification(
EventMsg::PatchApplyBegin(patch_begin_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
}
EventMsg::PatchApplyEnd(patch_end_event) => {
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = patch_end_event.call_id.clone();
complete_file_change_item(
conversation_id,
item_id,
build_file_change_end_item(&patch_end_event),
event_turn_id.clone(),
&outgoing,
&thread_state,
)
.await;
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
// Core still fans out these deprecated events for legacy clients;
// v2 clients receive the canonical FileChange item instead.
}
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
if matches!(
@@ -1239,69 +1161,43 @@ pub(crate) async fn apply_bespoke_event_handling(
return;
}
};
let Some(rollout_path) = conversation.rollout_path() else {
outgoing
.send_error(
request_id,
invalid_request("thread has no persisted rollout"),
)
.await;
return;
};
let response = match read_summary_from_rollout(
rollout_path.as_path(),
fallback_model_provider.as_str(),
)
.await
let fallback_cwd = conversation.config_snapshot().await.cwd;
let stored_thread = match conversation
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
)
.await
{
Ok(summary) => {
let fallback_cwd = conversation.config_snapshot().await.cwd;
let mut thread = summary_to_thread(summary, &fallback_cwd);
match read_rollout_items_from_rollout(rollout_path.as_path()).await {
Ok(items) => {
thread.turns = build_turns_from_rollout_items(&items);
thread.status = thread_watch_manager
.loaded_status_for_thread(&thread.id)
.await;
match find_thread_name_by_id(codex_home, &conversation_id).await {
Ok(name) => {
thread.name = name;
}
Err(err) => {
warn!(
"Failed to read thread name for {conversation_id}: {err}"
);
}
}
ThreadRollbackResponse { thread }
}
Err(err) => {
outgoing
.send_error(
request_id.clone(),
internal_error(format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
)),
)
.await;
return;
}
}
}
Ok(stored_thread) => stored_thread,
Err(err) => {
outgoing
.send_error(
request_id.clone(),
internal_error(format!(
"failed to load rollout `{}`: {err}",
rollout_path.display()
"failed to read thread {conversation_id} after rollback: {err}"
)),
)
.await;
return;
}
};
let loaded_status = thread_watch_manager
.loaded_status_for_thread(&conversation_id.to_string())
.await;
let response = match thread_rollback_response_from_stored_thread(
stored_thread,
fallback_model_provider.as_str(),
&fallback_cwd,
loaded_status,
) {
Ok(response) => response,
Err(err) => {
outgoing
.send_error(request_id.clone(), internal_error(err))
.await;
return;
}
};
outgoing.send_response(request_id, response).await;
}
@@ -1425,31 +1321,6 @@ async fn emit_turn_completed_with_status(
.await;
}
async fn complete_file_change_item(
conversation_id: ThreadId,
item_id: String,
item: ThreadItem,
turn_id: String,
outgoing: &ThreadScopedOutgoingMessageSender,
thread_state: &Arc<Mutex<ThreadState>>,
) {
thread_state
.lock()
.await
.turn_summary
.file_change_started
.remove(&item_id);
let notification = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id,
item,
};
outgoing
.send_server_notification(ServerNotification::ItemCompleted(notification))
.await;
}
#[allow(clippy::too_many_arguments)]
async fn start_command_execution_item(
conversation_id: &ThreadId,
@@ -1473,6 +1344,7 @@ async fn start_command_execution_item(
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id,
started_at_ms: now_unix_timestamp_ms(),
item: ThreadItem::CommandExecution {
id: item_id,
command,
@@ -1532,6 +1404,7 @@ async fn complete_command_execution_item(
let notification = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id,
completed_at_ms: now_unix_timestamp_ms(),
item,
};
outgoing
@@ -1579,6 +1452,7 @@ pub(crate) async fn maybe_emit_hook_prompt_item_completed(
let notification = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.to_string(),
completed_at_ms: now_unix_timestamp_ms(),
item: ThreadItem::HookPrompt {
id: hook_prompt.id,
fragments: hook_prompt
@@ -1673,6 +1547,25 @@ async fn handle_thread_rollback_failed(
}
}
fn thread_rollback_response_from_stored_thread(
stored_thread: codex_thread_store::StoredThread,
fallback_model_provider: &str,
fallback_cwd: &AbsolutePathBuf,
loaded_status: ThreadStatus,
) -> std::result::Result<ThreadRollbackResponse, String> {
let thread_id = stored_thread.thread_id;
let (mut thread, history) =
thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd);
let Some(history) = history else {
return Err(format!(
"thread {thread_id} did not include persisted history after rollback"
));
};
populate_thread_turns_from_history(&mut thread, &history.items, /*active_turn*/ None);
thread.status = loaded_status;
Ok(ThreadRollbackResponse { thread })
}
async fn respond_to_pending_interrupts(
thread_state: &Arc<Mutex<ThreadState>>,
outgoing: &ThreadScopedOutgoingMessageSender,
@@ -2002,38 +1895,28 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
}
}
fn map_file_change_approval_decision(
decision: FileChangeApprovalDecision,
) -> (ReviewDecision, Option<PatchApplyStatus>) {
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
match decision {
FileChangeApprovalDecision::Accept => (ReviewDecision::Approved, None),
FileChangeApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None),
FileChangeApprovalDecision::Decline => {
(ReviewDecision::Denied, Some(PatchApplyStatus::Declined))
}
FileChangeApprovalDecision::Cancel => {
(ReviewDecision::Abort, Some(PatchApplyStatus::Declined))
}
FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
FileChangeApprovalDecision::Decline => ReviewDecision::Denied,
FileChangeApprovalDecision::Cancel => ReviewDecision::Abort,
}
}
#[allow(clippy::too_many_arguments)]
async fn on_file_change_request_approval_response(
event_turn_id: String,
conversation_id: ThreadId,
item_id: String,
changes: Vec<FileUpdateChange>,
pending_request_id: RequestId,
receiver: oneshot::Receiver<ClientRequestResult>,
codex: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
thread_state: Arc<Mutex<ThreadState>>,
permission_guard: ThreadWatchActiveGuard,
) {
let response = receiver.await;
resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await;
drop(permission_guard);
let (decision, completion_status) = match response {
let decision = match response {
Ok(Ok(value)) => {
let response = serde_json::from_value::<FileChangeRequestApprovalResponse>(value)
.unwrap_or_else(|err| {
@@ -2043,39 +1926,19 @@ async fn on_file_change_request_approval_response(
}
});
let (decision, completion_status) =
map_file_change_approval_decision(response.decision);
// Allow EventMsg::PatchApplyEnd to emit ItemCompleted for accepted patches.
// Only short-circuit on declines/cancels/failures.
(decision, completion_status)
map_file_change_approval_decision(response.decision)
}
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
ReviewDecision::Denied
}
Err(err) => {
error!("request failed: {err:?}");
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
ReviewDecision::Denied
}
};
if let Some(status) = completion_status {
complete_file_change_item(
conversation_id,
item_id.clone(),
ThreadItem::FileChange {
id: item_id.clone(),
changes,
status,
},
event_turn_id.clone(),
&outgoing,
&thread_state,
)
.await;
}
if let Err(err) = codex
.submit(Op::PatchApproval {
id: item_id,
@@ -2212,6 +2075,13 @@ async fn on_command_execution_request_approval_response(
}
}
fn now_unix_timestamp_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2223,6 +2093,7 @@ mod tests {
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use chrono::Utc;
use codex_app_server_protocol::AutoReviewDecisionSource;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -2239,20 +2110,28 @@ mod tests {
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::UserMessageEvent;
use codex_thread_store::StoredThread;
use codex_thread_store::StoredThreadHistory;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use core_test_support::load_default_config_for_test;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
@@ -2277,6 +2156,71 @@ mod tests {
}
}
#[test]
fn rollback_response_rebuilds_pathless_thread_from_stored_history() -> Result<()> {
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000789")?;
let created_at = Utc::now();
let history_items = vec![
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: "before rollback".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
})),
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
message: "after rollback".to_string(),
phase: None,
memory_citation: None,
})),
];
let stored_thread = StoredThread {
thread_id,
rollout_path: None,
forked_from_id: None,
preview: "fallback preview".to_string(),
name: Some("Rollback thread".to_string()),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at,
updated_at: created_at,
archived_at: None,
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
token_usage: None,
first_user_message: Some("before rollback".to_string()),
history: Some(StoredThreadHistory {
thread_id,
items: history_items,
}),
};
let fallback_cwd = test_path_buf("/tmp").abs();
let response = thread_rollback_response_from_stored_thread(
stored_thread,
"fallback-provider",
&fallback_cwd,
ThreadStatus::NotLoaded,
)
.expect("rollback response should rebuild from stored history");
assert_eq!(response.thread.id, thread_id.to_string());
assert_eq!(response.thread.path, None);
assert_eq!(response.thread.preview, "before rollback");
assert_eq!(response.thread.name.as_deref(), Some("Rollback thread"));
assert_eq!(response.thread.status, ThreadStatus::NotLoaded);
assert_eq!(response.thread.turns.len(), 1);
assert_eq!(response.thread.turns[0].items.len(), 2);
Ok(())
}
fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent {
TurnCompleteEvent {
turn_id: turn_id.to_string(),
@@ -2359,7 +2303,6 @@ mod tests {
thread_state: Arc<Mutex<ThreadState>>,
thread_watch_manager: ThreadWatchManager,
analytics_events_client: AnalyticsEventsClient,
codex_home: PathBuf,
}
impl GuardianAssessmentTestContext {
@@ -2379,7 +2322,6 @@ mod tests {
self.thread_watch_manager.clone(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
&self.codex_home,
)
.await;
}
@@ -2687,12 +2629,7 @@ mod tests {
thread_id: conversation_id,
thread: conversation,
..
} = thread_manager
.start_thread(
config.clone(),
codex_core::thread_store_from_config(&config),
)
.await?;
} = thread_manager.start_thread(config.clone()).await?;
let thread_state = new_thread_state();
let thread_watch_manager = ThreadWatchManager::new();
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
@@ -2719,7 +2656,6 @@ mod tests {
"http://localhost".to_string(),
Some(false),
),
codex_home: codex_home.path().to_path_buf(),
};
guardian_context
@@ -2891,10 +2827,9 @@ mod tests {
#[test]
fn file_change_accept_for_session_maps_to_approved_for_session() {
let (decision, completion_status) =
let decision =
map_file_change_approval_decision(FileChangeApprovalDecision::AcceptForSession);
assert_eq!(decision, ReviewDecision::ApprovedForSession);
assert_eq!(completion_status, None);
}
#[test]
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
use std::sync::Arc;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_chatgpt::connectors;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
pub(super) fn merge_loaded_apps(
all_connectors: Option<&[AppInfo]>,
accessible_connectors: Option<&[AppInfo]>,
) -> Vec<AppInfo> {
let all_connectors_loaded = all_connectors.is_some();
let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded)
}
pub(super) fn should_send_app_list_updated_notification(
connectors: &[AppInfo],
accessible_loaded: bool,
all_loaded: bool,
) -> bool {
connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded)
}
pub(super) fn paginate_apps(
connectors: &[AppInfo],
start: usize,
limit: Option<u32>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let total = connectors.len();
if start > total {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total apps {total}"),
data: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(AppsListResponse { data, next_cursor })
}
pub(super) async fn send_app_list_updated_notification(
outgoing: &Arc<OutgoingMessageSender>,
data: Vec<AppInfo>,
) {
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
}
@@ -1,149 +0,0 @@
use std::collections::HashSet;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_plugin::AppConnectorId;
use tracing::warn;
pub(super) async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[AppConnectorId],
environment_manager: &EnvironmentManager,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
}
let connectors =
match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await {
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to load app metadata for plugin/read: {err:#}");
connectors::list_cached_all_connectors(config)
.await
.unwrap_or_default()
}
};
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
/*force_refetch*/ false,
environment_manager,
)
.await
{
Ok(status) if status.codex_apps_ready => status.connectors,
Ok(_) => {
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
Err(err) => {
warn!("failed to load app auth state for plugin/read: {err:#}");
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
};
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
plugin_connectors
.into_iter()
.map(|connector| {
let needs_auth = !accessible_ids.contains(connector.id.as_str());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth,
}
})
.collect()
}
pub(super) fn plugin_apps_needing_auth(
all_connectors: &[AppInfo],
accessible_connectors: &[AppInfo],
plugin_apps: &[AppConnectorId],
codex_apps_ready: bool,
) -> Vec<AppSummary> {
if !codex_apps_ready {
return Vec::new();
}
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
let plugin_app_ids = plugin_apps
.iter()
.map(|connector_id| connector_id.0.as_str())
.collect::<HashSet<_>>();
all_connectors
.iter()
.filter(|connector| {
plugin_app_ids.contains(connector.id.as_str())
&& !accessible_ids.contains(connector.id.as_str())
})
.cloned()
.map(|connector| AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
})
.collect()
}
#[cfg(test)]
mod tests {
use codex_app_server_protocol::AppInfo;
use codex_plugin::AppConnectorId;
use pretty_assertions::assert_eq;
use super::plugin_apps_needing_auth;
#[test]
fn plugin_apps_needing_auth_returns_empty_when_codex_apps_is_not_ready() {
let all_connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
assert_eq!(
plugin_apps_needing_auth(
&all_connectors,
&[],
&[AppConnectorId("alpha".to_string())],
/*codex_apps_ready*/ false,
),
Vec::new()
);
}
}
@@ -1,95 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::McpServerConfig;
use codex_core::config::Config;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_scopes;
use codex_mcp::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login_silent;
use tracing::warn;
use super::CodexMessageProcessor;
impl CodexMessageProcessor {
pub(super) async fn start_plugin_mcp_oauth_logins(
&self,
config: &Config,
plugin_mcp_servers: HashMap<String, McpServerConfig>,
) {
for (name, server) in plugin_mcp_servers {
let oauth_config = match oauth_login_support(&server.transport).await {
McpOAuthLoginSupport::Supported(config) => config,
McpOAuthLoginSupport::Unsupported => continue,
McpOAuthLoginSupport::Unknown(err) => {
warn!(
"MCP server may or may not require login for plugin install {name}: {err}"
);
continue;
}
};
let resolved_scopes = resolve_oauth_scopes(
/*explicit_scopes*/ None,
server.scopes.clone(),
oauth_config.discovered_scopes.clone(),
);
let store_mode = config.mcp_oauth_credentials_store_mode;
let callback_port = config.mcp_oauth_callback_port;
let callback_url = config.mcp_oauth_callback_url.clone();
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
tokio::spawn(async move {
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await;
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await
}
result => result,
};
let (success, error) = match final_result {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
};
let notification = ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: notification_name,
success,
error,
},
);
outgoing.send_server_notification(notification).await;
});
}
}
}
+1 -1
View File
@@ -477,7 +477,7 @@ async fn run_command(params: RunCommandParams) {
});
let stderr_handle = spawn_process_output(SpawnProcessOutputParams {
connection_id: request_id.connection_id,
process_id,
process_id: process_id.clone(),
output_rx: stderr_rx,
stdio_timeout_rx,
outgoing: Arc::clone(&outgoing),
-874
View File
@@ -1,874 +0,0 @@
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirements;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::warn;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"plugins",
"remote_control",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[async_trait]
pub(crate) trait UserConfigReloader: Send + Sync {
async fn reload_user_config(&self);
}
#[async_trait]
impl UserConfigReloader for ThreadManager {
async fn reload_user_config(&self) {
let thread_ids = self.list_thread_ids().await;
for thread_id in thread_ids {
let Ok(thread) = self.get_thread(thread_id).await else {
continue;
};
if let Err(err) = thread.submit(Op::ReloadUserConfig).await {
warn!("failed to request user config reload: {err}");
}
}
}
}
#[derive(Clone)]
pub(crate) struct ConfigApi {
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
}
impl ConfigApi {
pub(crate) fn new(
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
) -> Self {
Self {
config_manager,
user_config_reloader,
analytics_events_client,
}
}
pub(crate) async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| {
internal_error(format!(
"failed to resolve feature override precedence: {err}"
))
})
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_manager
.read_requirements()
.await
.map_err(map_error)?
.map(map_requirements_toml_to_api);
Ok(ConfigRequirementsReadResponse { requirements })
}
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let pending_changes =
collect_plugin_enabled_candidates([(&params.key_path, &params.value)].into_iter());
let response = self
.config_manager
.write_value(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
Ok(response)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let reload_user_config = params.reload_user_config;
let pending_changes = collect_plugin_enabled_candidates(
params
.edits
.iter()
.map(|edit| (&edit.key_path, &edit.value)),
);
let response = self
.config_manager
.batch_write(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
if reload_user_config {
self.user_config_reloader.reload_user_config().await;
}
Ok(response)
}
pub(crate) async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(invalid_request(message));
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
self.config_manager
.extend_runtime_feature_enablement(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
)
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
async fn emit_plugin_toggle_events(
&self,
pending_changes: std::collections::BTreeMap<String, bool>,
) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
let metadata =
installed_plugin_telemetry_metadata(self.config_manager.codex_home(), &plugin_id)
.await;
if enabled {
self.analytics_events_client.track_plugin_enabled(metadata);
} else {
self.analytics_events_client.track_plugin_disabled(metadata);
}
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
.into_iter()
.map(codex_app_server_protocol::AskForApproval::from)
.collect()
}),
allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| {
reviewers
.into_iter()
.map(codex_app_server_protocol::ApprovalsReviewer::from)
.collect()
}),
allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| {
modes
.into_iter()
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
.map(Into::into)
.collect::<Vec<WebSearchMode>>();
if !normalized.contains(&WebSearchMode::Disabled) {
normalized.push(WebSearchMode::Disabled);
}
normalized
}),
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite),
CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess),
CoreSandboxModeRequirement::ExternalSandbox => None,
}
}
fn map_residency_requirement_to_api(
residency: CoreResidencyRequirement,
) -> codex_app_server_protocol::ResidencyRequirement {
match residency {
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
}
}
fn map_network_requirements_to_api(
network: codex_config::NetworkRequirementsToml,
) -> NetworkRequirements {
let allowed_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
let denied_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
let allow_unix_sockets = network
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.filter(|entries| !entries.is_empty());
NetworkRequirements {
enabled: network.enabled,
http_port: network.http_port,
socks_port: network.socks_port,
allow_upstream_proxy: network.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
domains: network.domains.map(|domains| {
domains
.entries
.into_iter()
.map(|(pattern, permission)| {
(pattern, map_network_domain_permission_to_api(permission))
})
.collect()
}),
managed_allowed_domains_only: network.managed_allowed_domains_only,
allowed_domains,
denied_domains,
unix_sockets: network.unix_sockets.map(|unix_sockets| {
unix_sockets
.entries
.into_iter()
.map(|(path, permission)| {
(path, map_network_unix_socket_permission_to_api(permission))
})
.collect()
}),
allow_unix_sockets,
allow_local_binding: network.allow_local_binding,
}
}
fn map_network_domain_permission_to_api(
permission: codex_config::NetworkDomainPermissionToml,
) -> NetworkDomainPermission {
match permission {
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
}
}
fn map_network_unix_socket_permission_to_api(
permission: codex_config::NetworkUnixSocketPermissionToml,
) -> NetworkUnixSocketPermission {
match permission {
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
}
}
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
internal_error(err.to_string())
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: Some(json!({
"config_write_error_code": code,
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_manager::apply_runtime_feature_enablement;
use codex_analytics::AnalyticsEventsClient;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml;
use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use toml::Value as TomlValue;
#[derive(Default)]
struct RecordingUserConfigReloader {
call_count: AtomicUsize,
}
#[async_trait]
impl UserConfigReloader for RecordingUserConfigReloader {
async fn reload_user_config(&self) {
self.call_count.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn map_requirements_toml_to_api_converts_core_enums() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: Some(vec![
CoreAskForApproval::Never,
CoreAskForApproval::OnRequest,
]),
allowed_approvals_reviewers: Some(vec![
CoreApprovalsReviewer::User,
CoreApprovalsReviewer::AutoReview,
]),
allowed_sandbox_modes: Some(vec![
CoreSandboxModeRequirement::ReadOnly,
CoreSandboxModeRequirement::ExternalSandbox,
]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
guardian_policy_config: None,
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: std::collections::BTreeMap::from([
("apps".to_string(), false),
("personality".to_string(), true),
]),
}),
hooks: Some(ManagedHooksRequirementsToml {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![CoreMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![CoreHookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: Some(CoreResidencyRequirement::Us),
network: Some(CoreNetworkRequirementsToml {
enabled: Some(true),
http_port: Some(8080),
socks_port: Some(1080),
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
domains: Some(CoreNetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([
(
"api.openai.com".to_string(),
CoreNetworkDomainPermissionToml::Allow,
),
(
"example.com".to_string(),
CoreNetworkDomainPermissionToml::Deny,
),
]),
}),
managed_allowed_domains_only: Some(false),
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
entries: std::collections::BTreeMap::from([(
"/tmp/proxy.sock".to_string(),
CoreNetworkUnixSocketPermissionToml::Allow,
)]),
}),
allow_local_binding: Some(true),
}),
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.allowed_approval_policies,
Some(vec![
codex_app_server_protocol::AskForApproval::Never,
codex_app_server_protocol::AskForApproval::OnRequest,
])
);
assert_eq!(
mapped.allowed_approvals_reviewers,
Some(vec![
codex_app_server_protocol::ApprovalsReviewer::User,
codex_app_server_protocol::ApprovalsReviewer::AutoReview,
])
);
assert_eq!(
mapped.allowed_sandbox_modes,
Some(vec![SandboxMode::ReadOnly]),
);
assert_eq!(
mapped.allowed_web_search_modes,
Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]),
);
assert_eq!(
mapped.feature_requirements,
Some(std::collections::BTreeMap::from([
("apps".to_string(), false),
("personality".to_string(), true),
])),
);
assert_eq!(
mapped.hooks,
Some(ManagedHooksRequirements {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
pre_tool_use: vec![ConfiguredHookMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![ConfiguredHookHandler::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
permission_request: Vec::new(),
post_tool_use: Vec::new(),
session_start: Vec::new(),
user_prompt_submit: Vec::new(),
stop: Vec::new(),
}),
);
assert_eq!(
mapped.enforce_residency,
Some(codex_app_server_protocol::ResidencyRequirement::Us),
);
assert_eq!(
mapped.network,
Some(NetworkRequirements {
enabled: Some(true),
http_port: Some(8080),
socks_port: Some(1080),
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
domains: Some(std::collections::BTreeMap::from([
("api.openai.com".to_string(), NetworkDomainPermission::Allow,),
("example.com".to_string(), NetworkDomainPermission::Deny),
])),
managed_allowed_domains_only: Some(false),
allowed_domains: Some(vec!["api.openai.com".to_string()]),
denied_domains: Some(vec!["example.com".to_string()]),
unix_sockets: Some(std::collections::BTreeMap::from([(
"/tmp/proxy.sock".to_string(),
NetworkUnixSocketPermission::Allow,
)])),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
allow_local_binding: Some(true),
}),
);
}
#[test]
fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: Some(CoreNetworkRequirementsToml {
enabled: None,
http_port: None,
socks_port: None,
allow_upstream_proxy: None,
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
domains: None,
managed_allowed_domains_only: None,
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
entries: std::collections::BTreeMap::from([(
"/tmp/ignored.sock".to_string(),
CoreNetworkUnixSocketPermissionToml::None,
)]),
}),
allow_local_binding: None,
}),
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.network,
Some(NetworkRequirements {
enabled: None,
http_port: None,
socks_port: None,
allow_upstream_proxy: None,
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
domains: None,
managed_allowed_domains_only: None,
allowed_domains: None,
denied_domains: None,
unix_sockets: Some(std::collections::BTreeMap::from([(
"/tmp/ignored.sock".to_string(),
NetworkUnixSocketPermission::None,
)])),
allow_unix_sockets: None,
allow_local_binding: None,
}),
);
}
#[test]
fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: None,
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.allowed_web_search_modes,
Some(vec![WebSearchMode::Disabled])
);
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)
.expect("write config");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), false)]),
);
assert!(config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
}),
..Default::default()
}))
}))
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), true)]),
);
assert!(!config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn batch_write_reloads_user_config_when_requested() {
let codex_home = TempDir::new().expect("create temp dir");
let user_config_path = codex_home.path().join("config.toml");
std::fs::write(&user_config_path, "").expect("write config");
let reloader = Arc::new(RecordingUserConfigReloader::default());
let analytics_config = Arc::new(
codex_core::config::ConfigBuilder::default()
.build()
.await
.expect("load analytics config"),
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let config_api = ConfigApi::new(
ConfigManager::new(
codex_home.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
),
reloader.clone(),
AnalyticsEventsClient::new(
auth_manager,
analytics_config
.chatgpt_base_url
.trim_end_matches('/')
.to_string(),
analytics_config.analytics_enabled,
),
);
let response = config_api
.batch_write(ConfigBatchWriteParams {
edits: vec![codex_app_server_protocol::ConfigEdit {
key_path: "model".to_string(),
value: json!("gpt-5"),
merge_strategy: codex_app_server_protocol::MergeStrategy::Replace,
}],
file_path: Some(user_config_path.display().to_string()),
expected_version: None,
reload_user_config: true,
})
.await
.expect("batch write should succeed");
assert_eq!(
response,
ConfigWriteResponse {
status: codex_app_server_protocol::WriteStatus::Ok,
version: response.version.clone(),
file_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(
user_config_path.clone()
)
.expect("absolute config path"),
overridden_metadata: None,
}
);
assert_eq!(
std::fs::read_to_string(user_config_path).unwrap(),
"model = \"gpt-5\"\n"
);
assert_eq!(reloader.call_count.load(Ordering::Relaxed), 1);
}
}
+33 -8
View File
@@ -86,6 +86,7 @@ use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_login::AuthManager;
use codex_protocol::protocol::SessionSource;
pub use codex_rollout::StateDbHandle;
pub use codex_state::log_db::LogDbLayer;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -126,6 +127,8 @@ pub struct InProcessStartArgs {
pub feedback: CodexFeedback,
/// SQLite tracing layer used to flush recently emitted logs before feedback upload.
pub log_db: Option<LogDbLayer>,
/// Process-wide SQLite state handle shared with embedded app-server consumers.
pub state_db: Option<StateDbHandle>,
/// Environment manager used by core execution and filesystem operations.
pub environment_manager: Arc<EnvironmentManager>,
/// Startup warnings emitted after initialize succeeds.
@@ -251,6 +254,8 @@ pub struct InProcessClientHandle {
client: InProcessClientSender,
event_rx: mpsc::Receiver<InProcessServerEvent>,
runtime_handle: tokio::task::JoinHandle<()>,
#[cfg(test)]
_test_codex_home: Option<tempfile::TempDir>,
}
impl InProcessClientHandle {
@@ -418,6 +423,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
environment_manager: args.environment_manager,
feedback: args.feedback,
log_db: args.log_db,
state_db: args.state_db,
config_warnings: args.config_warnings,
session_source: args.session_source,
auth_manager,
@@ -717,6 +723,8 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
client: InProcessClientSender { client_tx },
event_rx,
runtime_handle,
#[cfg(test)]
_test_codex_home: None,
}
}
@@ -738,13 +746,22 @@ mod tests {
use codex_app_server_protocol::TurnStatus;
use codex_core::config::ConfigBuilder;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
async fn build_test_config() -> Config {
match ConfigBuilder::default().build().await {
async fn build_test_config(codex_home: &Path) -> Config {
match ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
{
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
Err(_) => Config::load_default_with_cli_overrides_for_codex_home(
codex_home.to_path_buf(),
Vec::new(),
)
.await
.expect("default config should load"),
}
}
@@ -752,15 +769,21 @@ mod tests {
session_source: SessionSource,
channel_capacity: usize,
) -> InProcessClientHandle {
let codex_home = TempDir::new().expect("temp dir");
let config = Arc::new(build_test_config(codex_home.path()).await);
let state_db = codex_rollout::state_db::try_init(config.as_ref())
.await
.expect("state db should initialize for in-process test");
let args = InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(build_test_config().await),
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: Some(state_db),
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source,
@@ -775,7 +798,9 @@ mod tests {
},
channel_capacity,
};
start(args).await.expect("in-process runtime should start")
let mut client = start(args).await.expect("in-process runtime should start");
client._test_codex_home = Some(codex_home);
client
}
async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle {
@@ -804,7 +829,7 @@ mod tests {
}
#[tokio::test]
async fn in_process_allows_device_key_requests_to_reach_device_key_api() {
async fn in_process_allows_device_key_requests_to_reach_device_key_processor() {
let client = start_test_client(SessionSource::Cli).await;
const MALFORMED_KEY_ID_MESSAGE: &str = concat!(
"invalid device key payload: keyId must be dk_hse_, dk_tpm_, or dk_osn_ ",
+58 -37
View File
@@ -54,6 +54,7 @@ use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_rollout::state_db as rollout_state_db;
use codex_state::log_db;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -73,25 +74,21 @@ use tracing_subscriber::util::SubscriberInitExt;
mod analytics_utils;
mod app_server_tracing;
mod bespoke_event_handling;
mod codex_message_processor;
mod command_exec;
mod config;
mod config_api;
mod config_manager;
mod config_manager_service;
mod connection_rpc_gate;
mod device_key_api;
mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
mod filters;
mod fs_api;
mod fs_watch;
mod fuzzy_file_search;
pub mod in_process;
mod message_processor;
mod models;
mod outgoing_message;
mod request_processors;
mod request_serialization;
mod server_request_error;
mod thread_state;
@@ -457,23 +454,6 @@ pub async fn run_main_with_transport_options(
.await
{
Ok(config) => {
let effective_toml = config.config_layer_stack.effective_config();
match effective_toml.try_into() {
Ok(config_toml) => {
if let Err(err) = codex_core::personality_migration::maybe_migrate_personality(
&config.codex_home,
&config_toml,
)
.await
{
warn!(error = %err, "Failed to run personality migration");
}
}
Err(err) => {
warn!(error = %err, "Failed to deserialize config for personality migration");
}
}
let discovered_thread_config_loader = configured_thread_config_loader(&config);
config_manager
.replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader));
@@ -487,23 +467,70 @@ pub async fn run_main_with_transport_options(
}
};
let mut config_warnings = Vec::new();
let config = match config_manager
let (mut config, should_run_personality_migration) = match config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
{
Ok(config) => config,
Ok(config) => (config, true),
Err(err) => {
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
config_manager.load_default_config().await.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
(
config_manager.load_default_config().await.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?,
false,
)
}
};
let state_db_result = rollout_state_db::try_init(&config).await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
match effective_toml.try_into() {
Ok(config_toml) => {
match codex_core::personality_migration::maybe_migrate_personality(
&config.codex_home,
&config_toml,
state_db.clone(),
)
.await
{
Ok(codex_core::personality_migration::PersonalityMigrationStatus::Applied) => {
config = config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| {
std::io::Error::new(
ErrorKind::InvalidData,
format!(
"error reloading config after personality migration: {err}"
),
)
})?;
}
Ok(
codex_core::personality_migration::PersonalityMigrationStatus::SkippedMarker
| codex_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality
| codex_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions,
) => {}
Err(err) => {
warn!(error = %err, "Failed to run personality migration");
}
}
}
Err(err) => {
warn!(error = %err, "Failed to deserialize config for personality migration");
}
}
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
@@ -571,13 +598,6 @@ pub async fn run_main_with_transport_options(
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
let state_db_result = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.model_provider_id.clone(),
)
.await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
let log_db = state_db.clone().map(log_db::start);
let log_db_layer = log_db
.clone()
@@ -749,6 +769,7 @@ pub async fn run_main_with_transport_options(
environment_manager,
feedback: feedback.clone(),
log_db,
state_db: state_db.clone(),
config_warnings,
session_source,
auth_manager,
File diff suppressed because it is too large Load Diff
@@ -290,6 +290,7 @@ async fn build_test_processor(
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
config_warnings: Vec::new(),
session_source: SessionSource::VSCode,
auth_manager,
+10
View File
@@ -1,6 +1,7 @@
use std::sync::Arc;
use codex_app_server_protocol::Model;
use codex_app_server_protocol::ModelServiceTier;
use codex_app_server_protocol::ModelUpgradeInfo;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_core::ThreadManager;
@@ -43,6 +44,15 @@ fn model_from_preset(preset: ModelPreset) -> Model {
input_modalities: preset.input_modalities,
supports_personality: preset.supports_personality,
additional_speed_tiers: preset.additional_speed_tiers,
service_tiers: preset
.service_tiers
.into_iter()
.map(|service_tier| ModelServiceTier {
id: service_tier.id,
name: service_tier.name,
description: service_tier.description,
})
.collect(),
is_default: preset.is_default,
}
}
+5 -51
View File
@@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -15,7 +14,6 @@ use codex_app_server_protocol::ServerRequestPayload;
use codex_otel::span_w3c_trace_context;
use codex_protocol::ThreadId;
use codex_protocol::protocol::W3cTraceContext;
use serde::Serialize;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -26,22 +24,17 @@ use tracing::warn;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::internal_error;
use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON;
pub(crate) use codex_app_server_transport::ConnectionId;
pub(crate) use codex_app_server_transport::OutgoingError;
pub(crate) use codex_app_server_transport::OutgoingMessage;
pub(crate) use codex_app_server_transport::OutgoingResponse;
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
#[cfg(test)]
use codex_protocol::account::PlanType;
pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId(pub(crate) u64);
impl fmt::Display for ConnectionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Stable identifier for a client request scoped to a transport connection.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionRequestId {
@@ -96,21 +89,6 @@ pub(crate) enum OutgoingEnvelope {
},
}
#[derive(Debug)]
pub(crate) struct QueuedOutgoingMessage {
pub(crate) message: OutgoingMessage,
pub(crate) write_complete_tx: Option<oneshot::Sender<()>>,
}
impl QueuedOutgoingMessage {
pub(crate) fn new(message: OutgoingMessage) -> Self {
Self {
message,
write_complete_tx: None,
}
}
}
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_server_request_id: AtomicI64,
@@ -665,30 +643,6 @@ impl OutgoingMessageSender {
}
}
/// Outgoing message from the server to the client.
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum OutgoingMessage {
Request(ServerRequest),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingResponse {
pub id: RequestId,
pub result: Result,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
#[cfg(test)]
mod tests {
use std::time::Duration;
@@ -0,0 +1,503 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_manager::ConfigManager;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::invalid_params;
use crate::models::supported_models;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::thread_status::ThreadWatchManager;
use crate::thread_status::resolve_thread_status;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AnalyticsJsonRpcError;
use codex_analytics::InputError;
use codex_analytics::TurnSteerRequestError;
use codex_app_server_protocol::Account;
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::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteParams;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceInterface;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeErrorInfo;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::McpResourceReadParams;
use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginParams;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerRefreshResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::McpServerToolCallResponse;
use codex_app_server_protocol::MemoryResetResponse;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PermissionProfileModificationParams;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget as ApiReviewTarget;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::SkillSummary;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalClearParams;
use codex_app_server_protocol::ThreadGoalClearResponse;
use codex_app_server_protocol::ThreadGoalClearedNotification;
use codex_app_server_protocol::ThreadGoalGetParams;
use codex_app_server_protocol::ThreadGoalGetResponse;
use codex_app_server_protocol::ThreadGoalSetParams;
use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
use codex_app_server_protocol::ThreadRealtimeListVoicesResponse;
use codex_app_server_protocol::ThreadRealtimeStartParams;
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::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnarchivedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WindowsSandboxReadiness;
use codex_app_server_protocol::WindowsSandboxReadinessResponse;
use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
use codex_core::ExternalGoalPreviousStatus;
use codex_core::ExternalGoalSet;
use codex_core::ForkSnapshot;
use codex_core::NewThread;
#[cfg(test)]
use codex_core::SessionMeta;
use codex_core::StartThreadOptions;
use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::find_thread_name_by_id;
use codex_core::find_thread_path_by_id_str;
use codex_core::path_utils;
#[cfg(test)]
use codex_core::read_head_for_summary;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_core::windows_sandbox::sandbox_setup_is_complete;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallError as CorePluginInstallError;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginLoadOutcome;
use codex_core_plugins::PluginReadRequest;
use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
use codex_core_plugins::loader::load_plugin_apps;
use codex_core_plugins::loader::load_plugin_mcp_servers;
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace_add::MarketplaceAddError;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveError;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_core_plugins::remote::RemoteMarketplace;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_feedback::CodexFeedback;
use codex_feedback::FeedbackAttachmentPath;
use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpServerStatusSnapshot;
use codex_mcp::McpSnapshotDetail;
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
use codex_mcp::discover_supported_scopes;
use codex_mcp::effective_mcp_servers;
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
use codex_mcp::resolve_oauth_scopes;
use codex_memories_write::clear_memory_roots_contents;
use codex_model_provider::ProviderAccountError;
use codex_model_provider::create_model_provider;
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::EventMsg;
#[cfg(test)]
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
#[cfg(test)]
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_rmcp_client::perform_oauth_login_return_url;
use codex_rollout::EventPersistenceMode;
use codex_rollout::is_persisted_rollout_item;
use codex_rollout::state_db::StateDbHandle;
use codex_rollout::state_db::reconcile_rollout;
use codex_state::ThreadMetadata;
use codex_state::log_db::LogDbLayer;
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
use codex_thread_store::GitInfoPatch as StoreGitInfoPatch;
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
use codex_thread_store::SortDirection as StoreSortDirection;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
use codex_thread_store::ThreadSortKey as StoreThreadSortKey;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use std::result::Result;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Mutex;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use toml::Value as TomlValue;
use tracing::Instrument;
use tracing::error;
use tracing::info;
use tracing::warn;
use uuid::Uuid;
#[cfg(test)]
use codex_app_server_protocol::ServerRequest;
mod account_processor;
mod apps_processor;
mod catalog_processor;
mod command_exec_processor;
mod config_processor;
mod device_key_processor;
mod external_agent_config_processor;
mod feedback_processor;
mod fs_processor;
mod git_processor;
mod initialize_processor;
mod marketplace_processor;
mod mcp_processor;
mod plugins;
mod process_exec_processor;
mod search;
mod thread_processor;
mod token_usage_replay;
mod turn_processor;
mod windows_sandbox_processor;
pub(crate) use account_processor::AccountRequestProcessor;
pub(crate) use apps_processor::AppsRequestProcessor;
pub(crate) use catalog_processor::CatalogRequestProcessor;
pub(crate) use command_exec_processor::CommandExecRequestProcessor;
pub(crate) use config_processor::ConfigRequestProcessor;
pub(crate) use device_key_processor::DeviceKeyRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
pub(crate) use feedback_processor::FeedbackRequestProcessor;
pub(crate) use fs_processor::FsRequestProcessor;
pub(crate) use git_processor::GitRequestProcessor;
pub(crate) use initialize_processor::InitializeRequestProcessor;
pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
pub(crate) use turn_processor::TurnRequestProcessor;
pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::ThreadStateManager;
use token_usage_replay::latest_token_usage_turn_id_from_rollout_items;
use token_usage_replay::send_thread_token_usage_update_to_connection;
mod config_errors;
mod request_errors;
mod thread_goal_processor;
mod thread_lifecycle;
mod thread_summary;
use self::config_errors::*;
use self::request_errors::*;
use self::thread_goal_processor::api_thread_goal_from_state;
use self::thread_lifecycle::*;
use self::thread_summary::*;
pub(crate) use self::thread_lifecycle::populate_thread_turns_from_history;
pub(crate) use self::thread_processor::thread_from_stored_thread;
#[cfg(test)]
pub(crate) use self::thread_summary::read_summary_from_rollout;
pub(crate) use self::thread_summary::summary_to_thread;
pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
let mut builder = ThreadHistoryBuilder::new();
for item in items {
if is_persisted_rollout_item(item, EventPersistenceMode::Limited) {
builder.handle_rollout_item(item);
}
}
builder.finish()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,350 @@
use super::*;
#[derive(Clone)]
pub(crate) struct AppsRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
impl AppsRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn apps_list(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.apps_list_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
async fn apps_list_inner(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if let Some(thread_id) = params.thread_id.as_deref() {
let (_, thread) = self.load_thread(thread_id).await?;
let _ = config
.features
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
}
let auth = self.auth_manager.auth().await;
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
{
return Ok(Some(AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
}
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(Some(AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
}
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
tokio::spawn(async move {
Self::apps_list_task(outgoing, request, params, config, environment_manager).await;
});
Ok(None)
}
async fn apps_list_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
) {
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
outgoing.send_result(request_id, result).await;
}
async fn apps_list_response(
outgoing: &Arc<OutgoingMessageSender>,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let AppsListParams {
cursor,
limit,
thread_id: _,
force_refetch,
} = params;
let start = match cursor {
Some(cursor) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
},
None => 0,
};
let (mut accessible_connectors, mut all_connectors) = tokio::join!(
connectors::list_cached_accessible_connectors_from_mcp_tools(&config),
connectors::list_cached_all_connectors(&config)
);
let cached_all_connectors = all_connectors.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let accessible_config = config.clone();
let accessible_tx = tx.clone();
tokio::spawn(async move {
let result =
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
&environment_manager,
)
.await
.map(|status| status.connectors)
.map_err(|err| format!("failed to load accessible apps: {err}"));
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
let all_config = config.clone();
tokio::spawn(async move {
let result = connectors::list_all_connectors_with_options(&all_config, force_refetch)
.await
.map_err(|err| format!("failed to list apps: {err}"));
let _ = tx.send(AppListLoadResult::Directory(result));
});
let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT;
let mut accessible_loaded = false;
let mut all_loaded = false;
let mut last_notified_apps = None;
if accessible_connectors.is_some() || all_connectors.is_some() {
let merged = connectors::with_app_enabled_state(
merge_loaded_apps(all_connectors.as_deref(), accessible_connectors.as_deref()),
&config,
);
if should_send_app_list_updated_notification(
merged.as_slice(),
accessible_loaded,
all_loaded,
) {
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged);
}
}
loop {
let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await {
Ok(Some(result)) => result,
Ok(None) => {
return Err(internal_error("failed to load app lists"));
}
Err(_) => {
let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs();
return Err(internal_error(format!(
"timed out waiting for app lists after {timeout_seconds} seconds"
)));
}
};
match result {
AppListLoadResult::Accessible(Ok(connectors)) => {
accessible_connectors = Some(connectors);
accessible_loaded = true;
}
AppListLoadResult::Accessible(Err(err)) => {
return Err(internal_error(err));
}
AppListLoadResult::Directory(Ok(connectors)) => {
all_connectors = Some(connectors);
all_loaded = true;
}
AppListLoadResult::Directory(Err(err)) => {
return Err(internal_error(err));
}
}
let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded);
let all_connectors_for_update =
if showing_interim_force_refetch && cached_all_connectors.is_some() {
cached_all_connectors.as_deref()
} else {
all_connectors.as_deref()
};
let accessible_connectors_for_update =
if showing_interim_force_refetch && !accessible_loaded {
None
} else {
accessible_connectors.as_deref()
};
let merged = connectors::with_app_enabled_state(
merge_loaded_apps(all_connectors_for_update, accessible_connectors_for_update),
&config,
);
if should_send_app_list_updated_notification(
merged.as_slice(),
accessible_loaded,
all_loaded,
) && last_notified_apps.as_ref() != Some(&merged)
{
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged.clone());
}
if accessible_loaded && all_loaded {
return paginate_apps(merged.as_slice(), start, limit);
}
}
}
async fn load_thread(
&self,
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
Ok((thread_id, thread))
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
}
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
enum AppListLoadResult {
Accessible(Result<Vec<AppInfo>, String>),
Directory(Result<Vec<AppInfo>, String>),
}
fn merge_loaded_apps(
all_connectors: Option<&[AppInfo]>,
accessible_connectors: Option<&[AppInfo]>,
) -> Vec<AppInfo> {
let all_connectors_loaded = all_connectors.is_some();
let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded)
}
fn should_send_app_list_updated_notification(
connectors: &[AppInfo],
accessible_loaded: bool,
all_loaded: bool,
) -> bool {
connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded)
}
fn paginate_apps(
connectors: &[AppInfo],
start: usize,
limit: Option<u32>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let total = connectors.len();
if start > total {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total apps {total}"),
data: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(AppsListResponse { data, next_cursor })
}
async fn send_app_list_updated_notification(
outgoing: &Arc<OutgoingMessageSender>,
data: Vec<AppInfo>,
) {
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
}
@@ -0,0 +1,600 @@
use super::*;
#[derive(Clone)]
pub(crate) struct CatalogRequestProcessor {
pub(super) auth_manager: Arc<AuthManager>,
pub(super) thread_manager: Arc<ThreadManager>,
pub(super) config: Arc<Config>,
pub(super) config_manager: ConfigManager,
pub(super) workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
fn skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
) -> Vec<codex_app_server_protocol::SkillMetadata> {
skills
.iter()
.map(|skill| {
let enabled = !disabled_paths.contains(&skill.path_to_skills_md);
codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
dependencies: skill.dependencies.clone().map(|dependencies| {
codex_app_server_protocol::SkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| codex_app_server_protocol::SkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
transport: tool.transport,
command: tool.command,
url: tool.url,
})
.collect(),
}
}),
path: skill.path_to_skills_md.clone(),
scope: skill.scope.into(),
enabled,
}
})
.collect()
}
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
hooks
.iter()
.map(|hook| HookMetadata {
key: hook.key.clone(),
event_name: hook.event_name.into(),
handler_type: hook.handler_type.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
enabled: hook.enabled,
is_managed: hook.is_managed,
})
.collect()
}
fn errors_to_info(
errors: &[codex_core::skills::SkillError],
) -> Vec<codex_app_server_protocol::SkillErrorInfo> {
errors
.iter()
.map(|err| codex_app_server_protocol::SkillErrorInfo {
path: err.path.to_path_buf(),
message: err.message.clone(),
})
.collect()
}
impl CatalogRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
config,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn skills_list(
&self,
params: SkillsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn hooks_list(
&self,
params: HooksListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.hooks_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn skills_config_write(
&self,
params: SkillsConfigWriteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_config_write_response_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn model_list(
&self,
params: ModelListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Self::list_models(self.thread_manager.clone(), params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn experimental_feature_list(
&self,
params: ExperimentalFeatureListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.experimental_feature_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn collaboration_mode_list(
&self,
params: CollaborationModeListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Self::list_collaboration_modes(self.thread_manager.clone(), params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn mock_experimental_method(
&self,
params: MockExperimentalMethodParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.mock_experimental_method_inner(params)
.await
.map(|response| Some(response.into()))
}
async fn resolve_cwd_config(
&self,
cwd: &Path,
) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> {
let cwd_abs =
AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?;
let config_layer_stack = self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
.map_err(|err| err.to_string())?;
Ok((cwd_abs, config_layer_stack))
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
async fn list_models(
thread_manager: Arc<ThreadManager>,
params: ModelListParams,
) -> Result<ModelListResponse, JSONRPCErrorError> {
let ModelListParams {
limit,
cursor,
include_hidden,
} = params;
let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await;
let total = models.len();
if total == 0 {
return Ok(ModelListResponse {
data: Vec::new(),
next_cursor: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?,
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total models {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let items = models[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(ModelListResponse {
data: items,
next_cursor,
})
}
async fn list_collaboration_modes(
thread_manager: Arc<ThreadManager>,
params: CollaborationModeListParams,
) -> Result<CollaborationModeListResponse, JSONRPCErrorError> {
let CollaborationModeListParams {} = params;
let items = thread_manager
.list_collaboration_modes()
.into_iter()
.map(Into::into)
.collect();
let response = CollaborationModeListResponse { data: items };
Ok(response)
}
async fn experimental_feature_list_response(
&self,
params: ExperimentalFeatureListParams,
) -> Result<ExperimentalFeatureListResponse, JSONRPCErrorError> {
let ExperimentalFeatureListParams { cursor, limit } = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let data = FEATURES
.iter()
.map(|spec| {
let (stage, display_name, description, announcement) = match spec.stage {
Stage::Experimental {
name,
menu_description,
announcement,
} => (
ApiExperimentalFeatureStage::Beta,
Some(name.to_string()),
Some(menu_description.to_string()),
Some(announcement.to_string()),
),
Stage::UnderDevelopment => (
ApiExperimentalFeatureStage::UnderDevelopment,
None,
None,
None,
),
Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None),
Stage::Deprecated => {
(ApiExperimentalFeatureStage::Deprecated, None, None, None)
}
Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None),
};
ApiExperimentalFeature {
name: spec.key.to_string(),
stage,
display_name,
description,
announcement,
enabled: config.features.enabled(spec.id)
&& (workspace_codex_plugins_enabled
|| !matches!(spec.id, Feature::Apps | Feature::Plugins)),
default_enabled: spec.default_enabled,
}
})
.collect::<Vec<_>>();
let total = data.len();
if total == 0 {
return Ok(ExperimentalFeatureListResponse {
data: Vec::new(),
next_cursor: None,
});
}
// Clamp to 1 so limit=0 cannot return a non-advancing page.
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
},
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total feature flags {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let data = data[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(ExperimentalFeatureListResponse { data, next_cursor })
}
async fn mock_experimental_method_inner(
&self,
params: MockExperimentalMethodParams,
) -> Result<MockExperimentalMethodResponse, JSONRPCErrorError> {
let MockExperimentalMethodParams { value } = params;
let response = MockExperimentalMethodResponse { echoed: value };
Ok(response)
}
async fn skills_list_response(
&self,
params: SkillsListParams,
) -> Result<SkillsListResponse, JSONRPCErrorError> {
let SkillsListParams {
cwds,
force_reload,
per_cwd_extra_user_roots,
} = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let cwd_set: HashSet<PathBuf> = cwds.iter().cloned().collect();
let mut extra_roots_by_cwd: HashMap<PathBuf, Vec<AbsolutePathBuf>> = HashMap::new();
for entry in per_cwd_extra_user_roots.unwrap_or_default() {
if !cwd_set.contains(&entry.cwd) {
warn!(
cwd = %entry.cwd.display(),
"ignoring per-cwd extra roots for cwd not present in skills/list cwds"
);
continue;
}
let mut valid_extra_roots = Vec::new();
for root in entry.extra_user_roots {
let root =
AbsolutePathBuf::from_absolute_path_checked(root.as_path()).map_err(|_| {
invalid_request(format!(
"skills/list perCwdExtraUserRoots extraUserRoots paths must be absolute: {}",
root.display()
))
})?;
valid_extra_roots.push(root);
}
extra_roots_by_cwd
.entry(entry.cwd)
.or_default()
.extend(valid_extra_roots);
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let skills_manager = self.thread_manager.skills_manager();
let plugins_manager = self.thread_manager.plugins_manager();
let fs = self
.thread_manager
.environment_manager()
.default_environment()
.map(|environment| environment.get_filesystem());
let mut data = Vec::new();
for cwd in cwds {
let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await {
Ok(resolved) => resolved,
Err(message) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message,
}],
});
continue;
}
};
let extra_roots = extra_roots_by_cwd
.get(&cwd)
.map_or(&[][..], std::vec::Vec::as_slice);
let effective_skill_roots = if workspace_codex_plugins_enabled {
let plugins_input = config.plugins_config_input();
plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input)
.await
} else {
Vec::new()
};
let skills_input = codex_core::skills::SkillsLoadInput::new(
cwd_abs.clone(),
effective_skill_roots,
config_layer_stack,
config.bundled_skills_enabled(),
);
let outcome = skills_manager
.skills_for_cwd_with_extra_user_roots(
&skills_input,
force_reload,
extra_roots,
fs.clone(),
)
.await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
});
}
Ok(SkillsListResponse { data })
}
/// Handle `hooks/list` by resolving hooks for each requested cwd.
async fn hooks_list_response(
&self,
params: HooksListParams,
) -> Result<HooksListResponse, JSONRPCErrorError> {
let HooksListParams { cwds } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let auth = self.auth_manager.auth().await;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
let config = match self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(cwd.clone()),
)
.await
{
Ok(config) => config,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: Vec::new(),
warnings: Vec::new(),
errors: vec![codex_app_server_protocol::HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
let plugins_input = config.plugins_config_input();
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&plugins_input,
/*plugin_hooks_feature_enabled*/ true,
)
.await
} else {
PluginLoadOutcome::default()
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
..Default::default()
});
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: hooks_to_info(&hooks.hooks),
warnings: hooks.warnings,
errors: Vec::new(),
});
}
Ok(HooksListResponse { data })
}
async fn skills_config_write_response_inner(
&self,
params: SkillsConfigWriteParams,
) -> Result<SkillsConfigWriteResponse, JSONRPCErrorError> {
let SkillsConfigWriteParams {
path,
name,
enabled,
} = params;
let edit = match (path, name) {
(Some(path), None) => ConfigEdit::SetSkillConfig {
path: path.into_path_buf(),
enabled,
},
(None, Some(name)) if !name.trim().is_empty() => {
ConfigEdit::SetSkillConfigByName { name, enabled }
}
_ => {
return Err(invalid_params(
"skills/config/write requires exactly one of path or name",
));
}
};
let edits = vec![edit];
ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
.map(|()| {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
SkillsConfigWriteResponse {
effective_enabled: enabled,
}
})
.map_err(|err| internal_error(format!("failed to update skill settings: {err}")))
}
}
@@ -0,0 +1,321 @@
use super::*;
#[derive(Clone)]
pub(crate) struct CommandExecRequestProcessor {
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
outgoing: Arc<OutgoingMessageSender>,
command_exec_manager: CommandExecManager,
}
impl CommandExecRequestProcessor {
pub(crate) fn new(
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
outgoing: Arc<OutgoingMessageSender>,
) -> Self {
Self {
arg0_paths,
config,
outgoing,
command_exec_manager: CommandExecManager::default(),
}
}
pub(crate) async fn one_off_command_exec(
&self,
request_id: &ConnectionRequestId,
params: CommandExecParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.exec_one_off_command(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn command_exec_write(
&self,
request_id: ConnectionRequestId,
params: CommandExecWriteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.write(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn command_exec_resize(
&self,
request_id: ConnectionRequestId,
params: CommandExecResizeParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.resize(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn command_exec_terminate(
&self,
request_id: ConnectionRequestId,
params: CommandExecTerminateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.terminate(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
self.command_exec_manager
.connection_closed(connection_id)
.await;
}
async fn exec_one_off_command(
&self,
request_id: &ConnectionRequestId,
params: CommandExecParams,
) -> Result<(), JSONRPCErrorError> {
self.exec_one_off_command_inner(request_id.clone(), params)
.await
}
async fn exec_one_off_command_inner(
&self,
request_id: ConnectionRequestId,
params: CommandExecParams,
) -> Result<(), JSONRPCErrorError> {
tracing::debug!("ExecOneOffCommand params: {params:?}");
let request = request_id.clone();
if params.command.is_empty() {
return Err(invalid_request("command must not be empty"));
}
let CommandExecParams {
command,
process_id,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
disable_output_cap,
disable_timeout,
timeout_ms,
cwd,
env: env_overrides,
size,
sandbox_policy,
permission_profile,
} = params;
if sandbox_policy.is_some() && permission_profile.is_some() {
return Err(invalid_request(
"`permissionProfile` cannot be combined with `sandboxPolicy`",
));
}
if size.is_some() && !tty {
return Err(invalid_params("command/exec size requires tty: true"));
}
if disable_output_cap && output_bytes_cap.is_some() {
return Err(invalid_params(
"command/exec cannot set both outputBytesCap and disableOutputCap",
));
}
if disable_timeout && timeout_ms.is_some() {
return Err(invalid_params(
"command/exec cannot set both timeoutMs and disableTimeout",
));
}
let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd));
let mut env = create_env(
&self.config.permissions.shell_environment_policy,
/*thread_id*/ None,
);
if let Some(env_overrides) = env_overrides {
for (key, value) in env_overrides {
match value {
Some(value) => {
env.insert(key, value);
}
None => {
env.remove(&key);
}
}
}
}
let timeout_ms = match timeout_ms {
Some(timeout_ms) => match u64::try_from(timeout_ms) {
Ok(timeout_ms) => Some(timeout_ms),
Err(_) => {
return Err(invalid_params(format!(
"command/exec timeoutMs must be non-negative, got {timeout_ms}"
)));
}
},
None => None,
};
let managed_network_requirements_enabled =
self.config.managed_network_requirements_enabled();
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec
.start_proxy(
self.config.permissions.permission_profile.get(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
{
Ok(started) => Some(started),
Err(err) => {
return Err(internal_error(format!(
"failed to start managed network proxy: {err}"
)));
}
},
None => None,
};
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let output_bytes_cap = if disable_output_cap {
None
} else {
Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP))
};
let expiration = if disable_timeout {
ExecExpiration::Cancellation(CancellationToken::new())
} else {
match timeout_ms {
Some(timeout_ms) => timeout_ms.into(),
None => ExecExpiration::DefaultTimeout,
}
};
let capture_policy = if disable_output_cap {
ExecCapturePolicy::FullBuffer
} else {
ExecCapturePolicy::ShellTool
};
let sandbox_cwd = if permission_profile.is_some() {
cwd.clone()
} else {
self.config.cwd.clone()
};
let exec_params = ExecParams {
command,
cwd: cwd.clone(),
expiration,
capture_policy,
env,
network: started_network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy),
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
windows_sandbox_private_desktop: self
.config
.permissions
.windows_sandbox_private_desktop,
justification: None,
arg0: None,
};
let effective_permission_profile = if let Some(permission_profile) = permission_profile {
let permission_profile =
codex_protocol::models::PermissionProfile::from(permission_profile);
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let configured_file_system_sandbox_policy =
self.config.permissions.file_system_sandbox_policy();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&configured_file_system_sandbox_policy,
);
let effective_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.config
.permissions
.permission_profile
.can_set(&effective_permission_profile)
.map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?;
effective_permission_profile
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
self.config
.permissions
.can_set_legacy_sandbox_policy(&policy, &sandbox_cwd)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
let permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.config
.permissions
.permission_profile
.can_set(&permission_profile)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
permission_profile
} else {
self.config.permissions.permission_profile()
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let request_for_task = request.clone();
let started_network_proxy_for_task = started_network_proxy;
let use_legacy_landlock = self.config.features.use_legacy_landlock();
let size = match size.map(crate::command_exec::terminal_size_from_protocol) {
Some(Ok(size)) => Some(size),
Some(Err(error)) => return Err(error),
None => None,
};
let exec_request = codex_core::exec::build_exec_request(
exec_params,
&effective_permission_profile,
&sandbox_cwd,
&codex_linux_sandbox_exe,
use_legacy_landlock,
)
.map_err(|err| internal_error(format!("exec failed: {err}")))?;
self.command_exec_manager
.start(StartCommandExecParams {
outgoing,
request_id: request_for_task,
process_id,
exec_request,
started_network_proxy: started_network_proxy_for_task,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
size,
})
.await
}
fn preserve_configured_deny_read_restrictions(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
configured_file_system_sandbox_policy: &FileSystemSandboxPolicy,
) {
file_system_sandbox_policy
.preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy);
}
}
#[cfg(test)]
#[path = "command_exec_processor_tests.rs"]
mod command_exec_processor_tests;
@@ -0,0 +1,38 @@
use super::*;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
#[test]
fn command_profile_preserves_configured_deny_read_restrictions() {
let readable_entry = FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: test_path_buf("/tmp/project").abs(),
},
access: FileSystemAccessMode::Read,
};
let deny_entry = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/project/**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
};
let mut file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]);
let mut configured_file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]);
configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2);
CommandExecRequestProcessor::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&configured_file_system_sandbox_policy,
);
let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]);
expected.glob_scan_max_depth = Some(2);
assert_eq!(file_system_sandbox_policy, expected);
}
@@ -0,0 +1,37 @@
use super::*;
fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> {
let mut current: Option<&(dyn std::error::Error + 'static)> = err
.get_ref()
.map(|source| source as &(dyn std::error::Error + 'static));
while let Some(source) = current {
if let Some(cloud_error) = source.downcast_ref::<CloudRequirementsLoadError>() {
return Some(cloud_error);
}
current = source.source();
}
None
}
pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let data = cloud_requirements_load_error(err).map(|cloud_error| {
let mut data = serde_json::json!({
"reason": "cloudRequirements",
"errorCode": format!("{:?}", cloud_error.code()),
"detail": cloud_error.to_string(),
});
if let Some(status_code) = cloud_error.status_code() {
data["statusCode"] = serde_json::json!(status_code);
}
if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth {
data["action"] = serde_json::json!("relogin");
}
data
});
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to load configuration: {err}"),
data,
}
}
@@ -0,0 +1,622 @@
use std::sync::Arc;
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirements;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_chatgpt::connectors;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_features::Feature;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::path::PathBuf;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"plugins",
"remote_control",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[derive(Clone)]
pub(crate) struct ConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
}
impl ConfigRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
) -> Self {
Self {
outgoing,
config_manager,
auth_manager,
thread_manager,
analytics_events_client,
remote_control_handle,
}
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_manager
.read_requirements()
.await
.map_err(map_error)?
.map(map_requirements_toml_to_api);
Ok(ConfigRequirementsReadResponse { requirements })
}
pub(crate) async fn value_write(
&self,
params: ConfigValueWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.write_value(params).await)
.await
.map(ClientResponsePayload::ConfigValueWrite)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.batch_write_inner(params).await)
.await
.map(ClientResponsePayload::ConfigBatchWrite)
}
pub(crate) async fn experimental_feature_enablement_set(
&self,
request_id: ConnectionRequestId,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true);
let response = self
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
.await?;
self.outgoing
.send_response_as(
request_id,
ClientResponsePayload::ExperimentalFeatureEnablementSet(response),
)
.await;
if should_refresh_apps_list {
self.refresh_apps_list_after_experimental_feature_enablement_set()
.await;
}
Ok(None)
}
pub(crate) async fn model_provider_capabilities_read(
&self,
) -> Result<ModelProviderCapabilitiesReadResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let provider = create_model_provider(config.model_provider, /*auth_manager*/ None);
let capabilities = provider.capabilities();
Ok(ModelProviderCapabilitiesReadResponse {
namespace_tools: capabilities.namespace_tools,
image_generation: capabilities.image_generation,
web_search: capabilities.web_search,
})
}
pub(crate) async fn handle_config_mutation(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
let Some(remote_control_handle) = &self.remote_control_handle else {
return;
};
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => {
remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl));
}
Err(error) => {
tracing::warn!(
"failed to load config for remote control enablement refresh after config mutation: {}",
error.message
);
}
}
}
async fn handle_config_mutation_result<T>(
&self,
result: std::result::Result<T, JSONRPCErrorError>,
) -> Result<T, JSONRPCErrorError> {
let response = result?;
self.handle_config_mutation().await;
Ok(response)
}
async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) {
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
Err(error) => {
tracing::warn!(
"failed to load config for apps list refresh after experimental feature enablement: {}",
error.message
);
return;
}
};
let auth = self.auth_manager.auth().await;
if !config.features.apps_enabled_for_auth(
auth.as_ref()
.is_some_and(codex_login::CodexAuth::uses_codex_backend),
) {
return;
}
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
tokio::spawn(async move {
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
&environment_manager,
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh directory apps after experimental feature enablement: {err:#}"
);
return;
}
};
let accessible_connectors = match accessible_connectors_result {
Ok(status) => status.connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh accessible apps after experimental feature enablement: {err:#}"
);
return;
}
};
let data = connectors::with_app_enabled_state(
connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
),
&config,
);
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
});
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<codex_core::config::Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| {
internal_error(format!(
"failed to resolve feature override precedence: {err}"
))
})
}
async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
[(&params.key_path, &params.value)].into_iter(),
);
let response = self
.config_manager
.write_value(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
Ok(response)
}
async fn batch_write_inner(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let reload_user_config = params.reload_user_config;
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
params
.edits
.iter()
.map(|edit| (&edit.key_path, &edit.value)),
);
let response = self
.config_manager
.batch_write(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
if reload_user_config {
self.reload_user_config().await;
}
Ok(response)
}
async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(invalid_request(message));
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
self.config_manager
.extend_runtime_feature_enablement(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
)
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
async fn reload_user_config(&self) {
let thread_ids = self.thread_manager.list_thread_ids().await;
for thread_id in thread_ids {
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
continue;
};
if let Err(err) = thread.submit(Op::ReloadUserConfig).await {
tracing::warn!("failed to request user config reload: {err}");
}
}
}
async fn emit_plugin_toggle_events(
&self,
pending_changes: std::collections::BTreeMap<String, bool>,
) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata(
self.config_manager.codex_home(),
&plugin_id,
)
.await;
if enabled {
self.analytics_events_client.track_plugin_enabled(metadata);
} else {
self.analytics_events_client.track_plugin_disabled(metadata);
}
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
.into_iter()
.map(codex_app_server_protocol::AskForApproval::from)
.collect()
}),
allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| {
reviewers
.into_iter()
.map(codex_app_server_protocol::ApprovalsReviewer::from)
.collect()
}),
allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| {
modes
.into_iter()
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
.map(Into::into)
.collect::<Vec<WebSearchMode>>();
if !normalized.contains(&WebSearchMode::Disabled) {
normalized.push(WebSearchMode::Disabled);
}
normalized
}),
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite),
CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess),
CoreSandboxModeRequirement::ExternalSandbox => None,
}
}
fn map_residency_requirement_to_api(
residency: CoreResidencyRequirement,
) -> codex_app_server_protocol::ResidencyRequirement {
match residency {
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
}
}
fn map_network_requirements_to_api(
network: codex_config::NetworkRequirementsToml,
) -> NetworkRequirements {
let allowed_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
let denied_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
let allow_unix_sockets = network
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.filter(|entries| !entries.is_empty());
NetworkRequirements {
enabled: network.enabled,
http_port: network.http_port,
socks_port: network.socks_port,
allow_upstream_proxy: network.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
domains: network.domains.map(|domains| {
domains
.entries
.into_iter()
.map(|(pattern, permission)| {
(pattern, map_network_domain_permission_to_api(permission))
})
.collect()
}),
managed_allowed_domains_only: network.managed_allowed_domains_only,
allowed_domains,
denied_domains,
unix_sockets: network.unix_sockets.map(|unix_sockets| {
unix_sockets
.entries
.into_iter()
.map(|(path, permission)| {
(path, map_network_unix_socket_permission_to_api(permission))
})
.collect()
}),
allow_unix_sockets,
allow_local_binding: network.allow_local_binding,
}
}
fn map_network_domain_permission_to_api(
permission: codex_config::NetworkDomainPermissionToml,
) -> NetworkDomainPermission {
match permission {
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
}
}
fn map_network_unix_socket_permission_to_api(
permission: codex_config::NetworkUnixSocketPermissionToml,
) -> NetworkUnixSocketPermission {
match permission {
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
}
}
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
internal_error(err.to_string())
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: Some(json!({
"config_write_error_code": code,
})),
}
}
@@ -1,8 +1,15 @@
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::DeviceKeyAlgorithm;
use codex_app_server_protocol::DeviceKeyCreateParams;
use codex_app_server_protocol::DeviceKeyCreateResponse;
@@ -28,112 +35,160 @@ use codex_device_key::RemoteControlClientEnrollmentAudience;
use codex_device_key::RemoteControlClientEnrollmentSignPayload;
use codex_state::DeviceKeyBindingRecord;
use codex_state::StateRuntime;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::OnceCell;
#[derive(Clone)]
pub(crate) struct DeviceKeyApi {
pub(crate) struct DeviceKeyRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
store: DeviceKeyStore,
}
impl DeviceKeyApi {
pub(crate) fn new(sqlite_home: PathBuf, default_provider: String) -> Self {
impl DeviceKeyRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
state_db: Option<Arc<StateRuntime>>,
) -> Self {
Self {
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(
sqlite_home,
default_provider,
))),
outgoing,
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(state_db))),
}
}
pub(crate) async fn create(
pub(crate) fn create(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyCreateParams,
) -> Result<DeviceKeyCreateResponse, JSONRPCErrorError> {
let info = self
.store
.create(DeviceKeyCreateRequest {
protection_policy: protection_policy_from_params(params.protection_policy),
binding: DeviceKeyBinding {
account_user_id: params.account_user_id,
client_id: params.client_id,
},
})
.await
.map_err(map_device_key_error)?;
Ok(create_response_from_info(info))
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/create",
device_key_requests_allowed,
move |store| async move { create_device_key(store, params).await },
);
}
pub(crate) async fn public(
pub(crate) fn public(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyPublicParams,
) -> Result<DeviceKeyPublicResponse, JSONRPCErrorError> {
let info = self
.store
.get_public(DeviceKeyGetPublicRequest {
key_id: params.key_id,
})
.await
.map_err(map_device_key_error)?;
Ok(public_response_from_info(info))
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/public",
device_key_requests_allowed,
move |store| async move { public_device_key(store, params).await },
);
}
pub(crate) async fn sign(
pub(crate) fn sign(
&self,
request_id: ConnectionRequestId,
params: DeviceKeySignParams,
) -> Result<DeviceKeySignResponse, JSONRPCErrorError> {
let signature = self
.store
.sign(DeviceKeySignRequest {
key_id: params.key_id,
payload: payload_from_params(params.payload),
})
.await
.map_err(map_device_key_error)?;
Ok(DeviceKeySignResponse {
signature_der_base64: STANDARD.encode(signature.signature_der),
signed_payload_base64: STANDARD.encode(signature.signed_payload),
algorithm: algorithm_from_store(signature.algorithm),
})
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/sign",
device_key_requests_allowed,
move |store| async move { sign_device_key(store, params).await },
);
}
fn spawn_request<R, F, Fut>(
&self,
request_id: ConnectionRequestId,
method: &'static str,
device_key_requests_allowed: bool,
run_request: F,
) where
R: Into<ClientResponsePayload> + Send + 'static,
F: FnOnce(DeviceKeyStore) -> Fut + Send + 'static,
Fut: Future<Output = Result<R, JSONRPCErrorError>> + Send + 'static,
{
let store = self.store.clone();
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
let result = if !device_key_requests_allowed {
Err(invalid_request(format!(
"{method} is not available over remote transports"
)))
} else {
run_request(store).await
};
outgoing.send_result(request_id, result).await;
});
}
}
async fn create_device_key(
store: DeviceKeyStore,
params: DeviceKeyCreateParams,
) -> Result<DeviceKeyCreateResponse, JSONRPCErrorError> {
let info = store
.create(DeviceKeyCreateRequest {
protection_policy: protection_policy_from_params(params.protection_policy),
binding: DeviceKeyBinding {
account_user_id: params.account_user_id,
client_id: params.client_id,
},
})
.await
.map_err(map_device_key_error)?;
Ok(create_response_from_info(info))
}
async fn public_device_key(
store: DeviceKeyStore,
params: DeviceKeyPublicParams,
) -> Result<DeviceKeyPublicResponse, JSONRPCErrorError> {
let info = store
.get_public(DeviceKeyGetPublicRequest {
key_id: params.key_id,
})
.await
.map_err(map_device_key_error)?;
Ok(public_response_from_info(info))
}
async fn sign_device_key(
store: DeviceKeyStore,
params: DeviceKeySignParams,
) -> Result<DeviceKeySignResponse, JSONRPCErrorError> {
let signature = store
.sign(DeviceKeySignRequest {
key_id: params.key_id,
payload: payload_from_params(params.payload),
})
.await
.map_err(map_device_key_error)?;
Ok(DeviceKeySignResponse {
signature_der_base64: STANDARD.encode(signature.signature_der),
signed_payload_base64: STANDARD.encode(signature.signed_payload),
algorithm: algorithm_from_store(signature.algorithm),
})
}
struct StateDeviceKeyBindingStore {
sqlite_home: PathBuf,
default_provider: String,
state_db: OnceCell<Arc<StateRuntime>>,
state_db: Option<Arc<StateRuntime>>,
}
impl StateDeviceKeyBindingStore {
fn new(sqlite_home: PathBuf, default_provider: String) -> Self {
Self {
sqlite_home,
default_provider,
state_db: OnceCell::new(),
}
fn new(state_db: Option<Arc<StateRuntime>>) -> Self {
Self { state_db }
}
async fn state_db(&self) -> Result<Arc<StateRuntime>, DeviceKeyError> {
let sqlite_home = self.sqlite_home.clone();
let default_provider = self.default_provider.clone();
self.state_db
.get_or_try_init(|| async move {
StateRuntime::init(sqlite_home, default_provider)
.await
.map_err(|err| DeviceKeyError::Platform(err.to_string()))
})
.await
.cloned()
.clone()
.ok_or_else(|| DeviceKeyError::Platform("sqlite state db unavailable".to_string()))
}
}
impl fmt::Debug for StateDeviceKeyBindingStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StateDeviceKeyBindingStore")
.field("sqlite_home", &self.sqlite_home)
.field("default_provider", &self.default_provider)
.field("has_state_db", &self.state_db.is_some())
.finish_non_exhaustive()
}
}

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