mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge origin/main into jif/add-session-id
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+8
@@ -1882,6 +1882,7 @@ dependencies = [
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout",
|
||||
@@ -2100,9 +2101,11 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-connectors",
|
||||
"codex-core",
|
||||
"codex-core-plugins",
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
"codex-plugin",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-cli",
|
||||
"pretty_assertions",
|
||||
@@ -2485,6 +2488,7 @@ name = "codex-core-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
@@ -2503,6 +2507,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-core-skills",
|
||||
@@ -3515,6 +3520,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-core-api",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3589,6 +3595,7 @@ dependencies = [
|
||||
"codex-install-context",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
"codex-model-provider",
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
"codex-otel",
|
||||
@@ -3902,6 +3909,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-pty",
|
||||
|
||||
@@ -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 {
|
||||
@@ -2130,7 +2285,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 +2637,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(),
|
||||
|
||||
@@ -587,11 +587,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),
|
||||
|
||||
@@ -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(
|
||||
@@ -686,10 +754,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 +781,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 +838,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 +877,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 +913,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(
|
||||
|
||||
@@ -99,10 +99,6 @@ pub mod legacy_core {
|
||||
pub use codex_core::personality_migration::*;
|
||||
}
|
||||
|
||||
pub mod plugins {
|
||||
pub use codex_core::plugins::PluginsManager;
|
||||
}
|
||||
|
||||
pub mod review_format {
|
||||
pub use codex_core::review_format::*;
|
||||
}
|
||||
|
||||
+43
-70
@@ -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": {
|
||||
@@ -3453,10 +3472,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 +3873,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 +4132,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 +4862,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 +5055,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": {
|
||||
|
||||
@@ -1032,6 +1032,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"FileChangeOutputDeltaNotification": {
|
||||
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
|
||||
"properties": {
|
||||
"delta": {
|
||||
"type": "string"
|
||||
@@ -5191,6 +5192,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Deprecated legacy apply_patch output stream notification.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
|
||||
+112
-103
@@ -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": {
|
||||
@@ -4289,6 +4289,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Deprecated legacy apply_patch output stream notification.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
@@ -8600,6 +8601,7 @@
|
||||
},
|
||||
"FileChangeOutputDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
|
||||
"properties": {
|
||||
"delta": {
|
||||
"type": "string"
|
||||
@@ -11954,6 +11956,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": {
|
||||
@@ -12339,6 +12358,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",
|
||||
@@ -12349,7 +12393,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/PluginSummary"
|
||||
"$ref": "#/definitions/v2/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -12396,6 +12440,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": [
|
||||
{
|
||||
@@ -12480,6 +12558,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"
|
||||
},
|
||||
@@ -15051,10 +15138,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": [
|
||||
@@ -16552,10 +16635,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": [
|
||||
@@ -17150,76 +17229,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": {
|
||||
|
||||
+112
-103
@@ -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": {
|
||||
@@ -5099,6 +5099,7 @@
|
||||
},
|
||||
"FileChangeOutputDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
|
||||
"properties": {
|
||||
"delta": {
|
||||
"type": "string"
|
||||
@@ -8608,6 +8609,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": {
|
||||
@@ -8993,6 +9011,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",
|
||||
@@ -9003,7 +9046,7 @@
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/PluginSummary"
|
||||
"$ref": "#/definitions/PluginShareListItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -9050,6 +9093,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": [
|
||||
{
|
||||
@@ -9134,6 +9211,15 @@
|
||||
"authPolicy": {
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
"availability": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAvailability"
|
||||
}
|
||||
],
|
||||
"default": "AVAILABLE",
|
||||
"description": "Availability state for installing and using the plugin."
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -11307,6 +11393,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "Deprecated legacy apply_patch output stream notification.",
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
@@ -12937,10 +13024,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": [
|
||||
@@ -14438,10 +14521,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": [
|
||||
@@ -15036,76 +15115,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": {
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
|
||||
"properties": {
|
||||
"delta": {
|
||||
"type": "string"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -1045,10 +1045,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
File diff suppressed because one or more lines are too long
+5
@@ -2,4 +2,9 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Deprecated legacy notification for `apply_patch` textual output.
|
||||
*
|
||||
* The server no longer emits this notification.
|
||||
*/
|
||||
export type FileChangeOutputDeltaNotification = { threadId: string, turnId: string, itemId: string, delta: 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, };
|
||||
+2
-2
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
-18
@@ -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, };
|
||||
+4
-2
@@ -270,6 +270,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,10 +283,13 @@ 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";
|
||||
@@ -395,8 +399,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";
|
||||
|
||||
@@ -14,6 +14,7 @@ pub use export::generate_ts_with_options;
|
||||
pub use export::generate_types;
|
||||
pub use jsonrpc_lite::*;
|
||||
pub use protocol::common::*;
|
||||
pub use protocol::event_mapping::*;
|
||||
pub use protocol::item_builders::*;
|
||||
pub use protocol::thread_history::*;
|
||||
pub use protocol::v1::ApplyPatchApprovalParams;
|
||||
|
||||
@@ -564,6 +564,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 +612,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"),
|
||||
@@ -1397,6 +1403,7 @@ server_notification_definitions! {
|
||||
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
/// Deprecated legacy apply_patch output stream notification.
|
||||
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
||||
FileChangePatchUpdated => "item/fileChange/patchUpdated" (v2::FileChangePatchUpdatedNotification),
|
||||
ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification),
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
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;
|
||||
use crate::protocol::v2::CollabAgentTool;
|
||||
use crate::protocol::v2::CollabAgentToolCallStatus;
|
||||
use crate::protocol::v2::CommandExecutionOutputDeltaNotification;
|
||||
use crate::protocol::v2::DynamicToolCallOutputContentItem;
|
||||
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;
|
||||
use crate::protocol::v2::ReasoningTextDeltaNotification;
|
||||
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.
|
||||
///
|
||||
/// This only covers the stateless event-to-notification projections that have a one-to-one
|
||||
/// mapping. Callers remain responsible for any surrounding state checks or side effects before
|
||||
/// invoking this helper.
|
||||
pub fn item_event_to_server_notification(
|
||||
msg: EventMsg,
|
||||
thread_id: &str,
|
||||
turn_id: &str,
|
||||
) -> ServerNotification {
|
||||
let thread_id = thread_id.to_string();
|
||||
let turn_id = turn_id.to_string();
|
||||
match msg {
|
||||
EventMsg::DynamicToolCallResponse(response) => {
|
||||
let status = if response.success {
|
||||
DynamicToolCallStatus::Completed
|
||||
} else {
|
||||
DynamicToolCallStatus::Failed
|
||||
};
|
||||
let duration_ms = i64::try_from(response.duration.as_millis()).ok();
|
||||
let item = ThreadItem::DynamicToolCall {
|
||||
id: response.call_id,
|
||||
namespace: response.namespace,
|
||||
tool: response.tool,
|
||||
arguments: response.arguments,
|
||||
status,
|
||||
content_items: Some(
|
||||
response
|
||||
.content_items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
CoreDynamicToolCallOutputContentItem::InputText { text } => {
|
||||
DynamicToolCallOutputContentItem::InputText { text }
|
||||
}
|
||||
CoreDynamicToolCallOutputContentItem::InputImage { image_url } => {
|
||||
DynamicToolCallOutputContentItem::InputImage { image_url }
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
success: Some(response.success),
|
||||
duration_ms,
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
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,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabAgentSpawnBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: Vec::new(),
|
||||
prompt: Some(begin_event.prompt),
|
||||
model: Some(begin_event.model),
|
||||
reasoning_effort: Some(begin_event.reasoning_effort),
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabAgentSpawnEnd(end_event) => {
|
||||
let has_receiver = end_event.new_thread_id.is_some();
|
||||
let status = match &end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => {
|
||||
CollabAgentToolCallStatus::Failed
|
||||
}
|
||||
_ if has_receiver => CollabAgentToolCallStatus::Completed,
|
||||
_ => CollabAgentToolCallStatus::Failed,
|
||||
};
|
||||
let (receiver_thread_ids, agents_states) = match end_event.new_thread_id {
|
||||
Some(id) => {
|
||||
let receiver_id = id.to_string();
|
||||
let received_status = CollabAgentState::from(end_event.status.clone());
|
||||
(
|
||||
vec![receiver_id.clone()],
|
||||
[(receiver_id, received_status)].into_iter().collect(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), HashMap::new()),
|
||||
};
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::SpawnAgent,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids,
|
||||
prompt: Some(end_event.prompt),
|
||||
model: Some(end_event.model),
|
||||
reasoning_effort: Some(end_event.reasoning_effort),
|
||||
agents_states,
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabAgentInteractionBegin(begin_event) => {
|
||||
let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()];
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids,
|
||||
prompt: Some(begin_event.prompt),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabAgentInteractionEnd(end_event) => {
|
||||
let status = match &end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => {
|
||||
CollabAgentToolCallStatus::Failed
|
||||
}
|
||||
_ => CollabAgentToolCallStatus::Completed,
|
||||
};
|
||||
let receiver_id = end_event.receiver_thread_id.to_string();
|
||||
let received_status = CollabAgentState::from(end_event.status);
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::SendInput,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![receiver_id.clone()],
|
||||
prompt: Some(end_event.prompt),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(receiver_id, received_status)].into_iter().collect(),
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabWaitingBegin(begin_event) => {
|
||||
let receiver_thread_ids = begin_event
|
||||
.receiver_thread_ids
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids,
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabWaitingEnd(end_event) => {
|
||||
let status = if end_event.statuses.values().any(|status| {
|
||||
matches!(
|
||||
status,
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound
|
||||
)
|
||||
}) {
|
||||
CollabAgentToolCallStatus::Failed
|
||||
} else {
|
||||
CollabAgentToolCallStatus::Completed
|
||||
};
|
||||
let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect();
|
||||
let agents_states = end_event
|
||||
.statuses
|
||||
.iter()
|
||||
.map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone())))
|
||||
.collect();
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::Wait,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids,
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states,
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabCloseBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabCloseEnd(end_event) => {
|
||||
let status = match &end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => {
|
||||
CollabAgentToolCallStatus::Failed
|
||||
}
|
||||
_ => CollabAgentToolCallStatus::Completed,
|
||||
};
|
||||
let receiver_id = end_event.receiver_thread_id.to_string();
|
||||
let agents_states = [(
|
||||
receiver_id.clone(),
|
||||
CollabAgentState::from(end_event.status),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::CloseAgent,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![receiver_id],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states,
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabResumeBegin(begin_event) => {
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: begin_event.call_id,
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: begin_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
};
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::CollabResumeEnd(end_event) => {
|
||||
let status = match &end_event.status {
|
||||
codex_protocol::protocol::AgentStatus::Errored(_)
|
||||
| codex_protocol::protocol::AgentStatus::NotFound => {
|
||||
CollabAgentToolCallStatus::Failed
|
||||
}
|
||||
_ => CollabAgentToolCallStatus::Completed,
|
||||
};
|
||||
let receiver_id = end_event.receiver_thread_id.to_string();
|
||||
let agents_states = [(
|
||||
receiver_id.clone(),
|
||||
CollabAgentState::from(end_event.status),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: end_event.call_id,
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status,
|
||||
sender_thread_id: end_event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![receiver_id],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states,
|
||||
};
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item,
|
||||
})
|
||||
}
|
||||
EventMsg::AgentMessageContentDelta(event) => {
|
||||
let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } =
|
||||
event;
|
||||
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
delta,
|
||||
})
|
||||
}
|
||||
EventMsg::PlanDelta(event) => ServerNotification::PlanDelta(PlanDeltaNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
}),
|
||||
EventMsg::ReasoningContentDelta(event) => {
|
||||
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
summary_index: event.summary_index,
|
||||
})
|
||||
}
|
||||
EventMsg::ReasoningRawContentDelta(event) => {
|
||||
ServerNotification::ReasoningTextDelta(ReasoningTextDeltaNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
content_index: event.content_index,
|
||||
})
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(event) => {
|
||||
ServerNotification::ReasoningSummaryPartAdded(ReasoningSummaryPartAddedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: event.item_id,
|
||||
summary_index: event.summary_index,
|
||||
})
|
||||
}
|
||||
EventMsg::ItemStarted(item_started_event) => {
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item: item_started_event.item.into(),
|
||||
})
|
||||
}
|
||||
EventMsg::ItemCompleted(item_completed_event) => {
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
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),
|
||||
})
|
||||
}
|
||||
EventMsg::PatchApplyUpdated(event) => {
|
||||
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: event.call_id,
|
||||
changes: convert_patch_changes(&event.changes),
|
||||
})
|
||||
}
|
||||
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item: build_command_execution_begin_item(&exec_command_begin_event),
|
||||
})
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
|
||||
let item_id = exec_command_output_delta_event.call_id;
|
||||
let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
|
||||
ServerNotification::CommandExecutionOutputDelta(
|
||||
CommandExecutionOutputDeltaNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
delta,
|
||||
},
|
||||
)
|
||||
}
|
||||
EventMsg::TerminalInteraction(terminal_event) => {
|
||||
ServerNotification::TerminalInteraction(TerminalInteractionNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id: terminal_event.call_id,
|
||||
process_id: terminal_event.process_id,
|
||||
stdin: terminal_event.stdin,
|
||||
})
|
||||
}
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event) => {
|
||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item: build_command_execution_end_item(&exec_command_end_event),
|
||||
})
|
||||
}
|
||||
_ => unreachable!("unsupported item event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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,
|
||||
expected: ItemStartedNotification,
|
||||
) {
|
||||
match notification {
|
||||
ServerNotification::ItemStarted(payload) => assert_eq!(payload, expected),
|
||||
other => panic!("expected item started notification, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_item_completed_server_notification(
|
||||
notification: ServerNotification,
|
||||
expected: ItemCompletedNotification,
|
||||
) {
|
||||
match notification {
|
||||
ServerNotification::ItemCompleted(payload) => assert_eq!(payload, expected),
|
||||
other => panic!("expected item completed notification, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_command_execution_output_delta_server_notification(
|
||||
notification: ServerNotification,
|
||||
expected: CommandExecutionOutputDeltaNotification,
|
||||
) {
|
||||
match notification {
|
||||
ServerNotification::CommandExecutionOutputDelta(payload) => {
|
||||
assert_eq!(payload, expected)
|
||||
}
|
||||
other => panic!("expected command execution output delta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_resume_begin_maps_to_item_started_resume_agent() {
|
||||
let event = CollabResumeBeginEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
sender_thread_id: ThreadId::new(),
|
||||
receiver_thread_id: ThreadId::new(),
|
||||
receiver_agent_nickname: None,
|
||||
receiver_agent_role: None,
|
||||
};
|
||||
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::CollabResumeBegin(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::CollabAgentToolCall {
|
||||
id: event.call_id,
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status: CollabAgentToolCallStatus::InProgress,
|
||||
sender_thread_id: event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![event.receiver_thread_id.to_string()],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: HashMap::new(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_resume_end_maps_to_item_completed_resume_agent() {
|
||||
let event = CollabResumeEndEvent {
|
||||
call_id: "call-2".to_string(),
|
||||
sender_thread_id: ThreadId::new(),
|
||||
receiver_thread_id: ThreadId::new(),
|
||||
receiver_agent_nickname: None,
|
||||
receiver_agent_role: None,
|
||||
status: codex_protocol::protocol::AgentStatus::NotFound,
|
||||
};
|
||||
|
||||
let receiver_id = event.receiver_thread_id.to_string();
|
||||
let notification = item_event_to_server_notification(
|
||||
EventMsg::CollabResumeEnd(event.clone()),
|
||||
"thread-2",
|
||||
"turn-2",
|
||||
);
|
||||
assert_item_completed_server_notification(
|
||||
notification,
|
||||
ItemCompletedNotification {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
item: ThreadItem::CollabAgentToolCall {
|
||||
id: event.call_id,
|
||||
tool: CollabAgentTool::ResumeAgent,
|
||||
status: CollabAgentToolCallStatus::Failed,
|
||||
sender_thread_id: event.sender_thread_id.to_string(),
|
||||
receiver_thread_ids: vec![receiver_id.clone()],
|
||||
prompt: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: [(
|
||||
receiver_id,
|
||||
CollabAgentState::from(codex_protocol::protocol::AgentStatus::NotFound),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[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(
|
||||
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: "call-1".to_string(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"hello".to_vec(),
|
||||
}),
|
||||
"thread-1",
|
||||
"turn-1",
|
||||
);
|
||||
|
||||
assert_command_execution_output_delta_server_notification(
|
||||
notification,
|
||||
CommandExecutionOutputDeltaNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
item_id: "call-1".to_string(),
|
||||
delta: "hello".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`.
|
||||
|
||||
pub mod common;
|
||||
pub mod event_mapping;
|
||||
pub mod item_builders;
|
||||
mod mappers;
|
||||
mod serde_helpers;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -3738,6 +3738,7 @@ 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
|
||||
@@ -3842,6 +3843,7 @@ 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
|
||||
@@ -4607,6 +4609,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 +4651,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 +4666,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 +4852,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 +4878,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>,
|
||||
}
|
||||
|
||||
@@ -6992,6 +7037,9 @@ pub struct CommandExecOutputDeltaNotification {
|
||||
pub cap_reached: bool,
|
||||
}
|
||||
|
||||
/// Deprecated legacy notification for `apply_patch` textual output.
|
||||
///
|
||||
/// The server no longer emits this notification.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -10644,6 +10692,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) {
|
||||
@@ -10709,36 +10774,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!(
|
||||
|
||||
@@ -44,6 +44,7 @@ codex-features = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
|
||||
@@ -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.
|
||||
@@ -201,8 +201,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`.
|
||||
@@ -222,7 +223,7 @@ Example with notification opt-out:
|
||||
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
|
||||
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata.
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes plugin or session imports, the server emits `externalAgentConfig/import/completed` after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
|
||||
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
|
||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads.
|
||||
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
|
||||
@@ -276,7 +277,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,7 +305,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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -418,9 +419,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": {
|
||||
@@ -1183,7 +1184,7 @@ There are additional item-specific events:
|
||||
#### fileChange
|
||||
|
||||
- `item/fileChange/patchUpdated` - when `features.apply_patch_streaming_events` is enabled, streams structured file-change snapshots parsed from the model-generated patch before it is executed.
|
||||
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
|
||||
- `item/fileChange/outputDelta` - deprecated legacy protocol entry for `apply_patch` text output; retained for compatibility but no longer emitted by the server.
|
||||
|
||||
### Errors
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@ use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) async fn load_plugin_app_summaries(
|
||||
@@ -113,7 +113,7 @@ pub(super) fn plugin_apps_needing_auth(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::plugin_apps_needing_auth;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::*;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_core_plugins::remote::is_valid_remote_plugin_id;
|
||||
use codex_core_plugins::remote::validate_remote_plugin_id;
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
pub(super) async fn plugin_list(
|
||||
@@ -37,14 +40,15 @@ impl CodexMessageProcessor {
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let plugins_input = config.plugins_config_input();
|
||||
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
|
||||
&config,
|
||||
&plugins_input,
|
||||
auth.clone(),
|
||||
&roots,
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
|
||||
let config_for_marketplace_listing = config.clone();
|
||||
let config_for_marketplace_listing = plugins_input.clone();
|
||||
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
|
||||
let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
|
||||
let outcome = plugins_manager_for_marketplace_listing
|
||||
@@ -76,6 +80,7 @@ impl CodexMessageProcessor {
|
||||
source: marketplace_plugin_source_to_info(plugin.source),
|
||||
install_policy: plugin.policy.installation.into(),
|
||||
auth_policy: plugin.policy.authentication.into(),
|
||||
availability: PluginAvailability::Available,
|
||||
interface: plugin.interface.map(local_plugin_interface_to_info),
|
||||
})
|
||||
.collect(),
|
||||
@@ -145,7 +150,7 @@ impl CodexMessageProcessor {
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
{
|
||||
match plugins_manager
|
||||
.featured_plugin_ids_for_config(&config, auth.as_ref())
|
||||
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(featured_plugin_ids) => featured_plugin_ids,
|
||||
@@ -201,6 +206,7 @@ impl CodexMessageProcessor {
|
||||
});
|
||||
|
||||
let config = self.load_latest_config(config_cwd).await?;
|
||||
let plugins_input = config.plugins_config_input();
|
||||
|
||||
let plugin = match read_source {
|
||||
Ok(marketplace_path) => {
|
||||
@@ -209,7 +215,7 @@ impl CodexMessageProcessor {
|
||||
marketplace_path,
|
||||
};
|
||||
let outcome = plugins_manager
|
||||
.read_plugin_for_config(&config, &request)
|
||||
.read_plugin_for_config(&plugins_input, &request)
|
||||
.await
|
||||
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
@@ -241,6 +247,7 @@ impl CodexMessageProcessor {
|
||||
enabled: outcome.plugin.enabled,
|
||||
install_policy: outcome.plugin.policy.installation.into(),
|
||||
auth_policy: outcome.plugin.policy.authentication.into(),
|
||||
availability: PluginAvailability::Available,
|
||||
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
|
||||
},
|
||||
description: outcome.plugin.description,
|
||||
@@ -256,15 +263,15 @@ impl CodexMessageProcessor {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin read is not enabled"));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_name)?;
|
||||
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
@@ -279,7 +286,7 @@ impl CodexMessageProcessor {
|
||||
.app_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(codex_core::plugins::AppConnectorId)
|
||||
.map(codex_plugin::AppConnectorId)
|
||||
.collect::<Vec<_>>();
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
|
||||
@@ -295,6 +302,61 @@ impl CodexMessageProcessor {
|
||||
Ok(PluginReadResponse { plugin })
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_skill_read(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: PluginSkillReadParams,
|
||||
) {
|
||||
let result = self.plugin_skill_read_response(params).await;
|
||||
self.outgoing.send_result(request_id, result).await;
|
||||
}
|
||||
|
||||
async fn plugin_skill_read_response(
|
||||
&self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
|
||||
let PluginSkillReadParams {
|
||||
remote_marketplace_name,
|
||||
remote_plugin_id,
|
||||
skill_name,
|
||||
} = params;
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
if skill_name.is_empty() {
|
||||
return Err(invalid_request(
|
||||
"invalid remote plugin skill name: cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&remote_marketplace_name,
|
||||
&remote_plugin_id,
|
||||
&skill_name,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
|
||||
})?;
|
||||
|
||||
Ok(PluginSkillReadResponse {
|
||||
contents: remote_skill_detail.contents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn plugin_share_save(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
@@ -325,14 +387,16 @@ impl CodexMessageProcessor {
|
||||
let result = codex_core_plugins::remote::save_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
plugin_path.as_path(),
|
||||
config.codex_home.as_path(),
|
||||
&plugin_path,
|
||||
remote_plugin_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
|
||||
let remote_plugin_id = result.remote_plugin_id;
|
||||
self.clear_plugin_related_caches();
|
||||
Ok(PluginShareSaveResponse {
|
||||
remote_plugin_id: result.remote_plugin_id,
|
||||
remote_plugin_id,
|
||||
share_url: result.share_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -356,11 +420,24 @@ impl CodexMessageProcessor {
|
||||
let data = codex_core_plugins::remote::list_remote_plugin_shares(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
|
||||
.into_iter()
|
||||
.map(remote_plugin_summary_to_info)
|
||||
.map(|summary| {
|
||||
let RemoteCatalogPluginShareSummary {
|
||||
summary,
|
||||
share_url,
|
||||
local_plugin_path,
|
||||
} = summary;
|
||||
let plugin = remote_plugin_summary_to_info(summary);
|
||||
PluginShareListItem {
|
||||
plugin,
|
||||
share_url: share_url.unwrap_or_default(),
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(PluginShareListResponse { data })
|
||||
}
|
||||
@@ -390,6 +467,7 @@ impl CodexMessageProcessor {
|
||||
codex_core_plugins::remote::delete_remote_plugin_share(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.as_path(),
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
@@ -503,17 +581,17 @@ impl CodexMessageProcessor {
|
||||
async fn remote_plugin_install_response(
|
||||
&self,
|
||||
remote_marketplace_name: String,
|
||||
plugin_name: String,
|
||||
remote_plugin_id: String,
|
||||
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
return Err(invalid_request("remote plugin install is not enabled"));
|
||||
}
|
||||
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
|
||||
)));
|
||||
}
|
||||
validate_remote_plugin_id(&remote_plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -524,7 +602,7 @@ impl CodexMessageProcessor {
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&remote_marketplace_name,
|
||||
&plugin_name,
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -533,14 +611,28 @@ impl CodexMessageProcessor {
|
||||
"read remote plugin details before install",
|
||||
)
|
||||
})?;
|
||||
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
|
||||
let remote_plugin_id = &remote_detail.summary.id;
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin {remote_plugin_id} is disabled by admin"
|
||||
)));
|
||||
}
|
||||
if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable {
|
||||
return Err(invalid_request(format!(
|
||||
"remote plugin {plugin_name} is not available for install"
|
||||
"remote plugin {remote_plugin_id} is not available for install"
|
||||
)));
|
||||
}
|
||||
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
|
||||
// Direct install writes the same cache tree that installed-plugin sync
|
||||
// prunes before the backend installed snapshot can include this plugin.
|
||||
let _remote_plugin_cache_mutation =
|
||||
codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight(
|
||||
config.codex_home.as_path(),
|
||||
&actual_remote_marketplace_name,
|
||||
&remote_detail.summary.name,
|
||||
);
|
||||
let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle(
|
||||
&plugin_name,
|
||||
&remote_plugin_id,
|
||||
&actual_remote_marketplace_name,
|
||||
&remote_detail.summary.name,
|
||||
remote_detail.release_version.as_deref(),
|
||||
@@ -562,7 +654,7 @@ impl CodexMessageProcessor {
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&actual_remote_marketplace_name,
|
||||
&plugin_name,
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?;
|
||||
@@ -570,11 +662,17 @@ impl CodexMessageProcessor {
|
||||
self.thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config,
|
||||
&config.plugins_config_input(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
|
||||
let mut plugin_metadata =
|
||||
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await;
|
||||
plugin_metadata.remote_plugin_id = Some(remote_plugin_id);
|
||||
self.analytics_events_client
|
||||
.track_plugin_installed(plugin_metadata);
|
||||
|
||||
let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await;
|
||||
if !plugin_mcp_servers.is_empty() {
|
||||
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
|
||||
@@ -602,7 +700,7 @@ impl CodexMessageProcessor {
|
||||
config: &Config,
|
||||
is_chatgpt_auth: bool,
|
||||
plugin_id: &str,
|
||||
plugin_apps: &[codex_core::plugins::AppConnectorId],
|
||||
plugin_apps: &[codex_plugin::AppConnectorId],
|
||||
) -> Vec<AppSummary> {
|
||||
if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) {
|
||||
return Vec::new();
|
||||
@@ -675,14 +773,14 @@ impl CodexMessageProcessor {
|
||||
params: PluginUninstallParams,
|
||||
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
|
||||
let PluginUninstallParams { plugin_id } = params;
|
||||
if codex_core::plugins::PluginId::parse(&plugin_id).is_err()
|
||||
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
|
||||
if codex_plugin::PluginId::parse(&plugin_id).is_err()
|
||||
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
|
||||
{
|
||||
return Err(invalid_request(
|
||||
"invalid plugin id: expected a local plugin id or remote plugin id",
|
||||
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`",
|
||||
));
|
||||
}
|
||||
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
|
||||
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
|
||||
return self.remote_plugin_uninstall_response(plugin_id).await;
|
||||
}
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
@@ -773,9 +871,7 @@ impl CodexMessageProcessor {
|
||||
{
|
||||
return Err(invalid_request("remote plugin uninstall is not enabled"));
|
||||
}
|
||||
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
|
||||
return Err(invalid_request("invalid remote plugin id"));
|
||||
}
|
||||
validate_remote_plugin_id(&plugin_id)?;
|
||||
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
@@ -798,7 +894,7 @@ impl CodexMessageProcessor {
|
||||
self.on_effective_plugins_changed(config.clone());
|
||||
}
|
||||
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config,
|
||||
&config.plugins_config_input(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
@@ -811,10 +907,13 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
|
||||
plugin_name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
|
||||
is_valid_remote_plugin_id(plugin_name)
|
||||
&& (plugin_name.starts_with("plugins~")
|
||||
|| plugin_name.starts_with("plugins_")
|
||||
|| plugin_name.starts_with("app_")
|
||||
|| plugin_name.starts_with("asdk_app_")
|
||||
|| plugin_name.starts_with("connector_"))
|
||||
}
|
||||
|
||||
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
|
||||
@@ -841,6 +940,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS
|
||||
enabled: summary.enabled,
|
||||
install_policy: summary.install_policy,
|
||||
auth_policy: summary.auth_policy,
|
||||
availability: summary.availability,
|
||||
interface: summary.interface,
|
||||
}
|
||||
}
|
||||
@@ -891,7 +991,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
}
|
||||
}
|
||||
RemotePluginCatalogError::InvalidPluginPath { .. }
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
|
||||
| RemotePluginCatalogError::ArchiveTooLarge { .. }
|
||||
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("{context}: {err}"),
|
||||
data: None,
|
||||
@@ -900,7 +1001,10 @@ fn remote_plugin_catalog_error_to_jsonrpc(
|
||||
| RemotePluginCatalogError::Request { .. }
|
||||
| RemotePluginCatalogError::UnexpectedStatus { .. }
|
||||
| RemotePluginCatalogError::Decode { .. }
|
||||
| RemotePluginCatalogError::InvalidBaseUrl(_)
|
||||
| RemotePluginCatalogError::InvalidBaseUrlPath
|
||||
| RemotePluginCatalogError::UnexpectedPluginId { .. }
|
||||
| RemotePluginCatalogError::UnexpectedSkillName { .. }
|
||||
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
|
||||
| RemotePluginCatalogError::Archive { .. }
|
||||
| RemotePluginCatalogError::ArchiveJoin(_)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::plugins::PluginId;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core_plugins::PluginInstallRequest;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
|
||||
@@ -20,6 +19,7 @@ use codex_external_agent_migration::missing_command_names;
|
||||
use codex_external_agent_migration::missing_subagent_names;
|
||||
use codex_external_agent_sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_sessions::detect_recent_sessions;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -1146,8 +1146,9 @@ fn configured_marketplace_plugins(
|
||||
config: &Config,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> io::Result<BTreeMap<String, HashSet<String>>> {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.list_marketplaces_for_config(&plugins_input, &[])
|
||||
.map_err(|err| {
|
||||
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
|
||||
})?;
|
||||
|
||||
@@ -32,11 +32,11 @@ 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::PluginId;
|
||||
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;
|
||||
|
||||
@@ -65,6 +65,7 @@ use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::thread_store_from_config;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_features::Feature;
|
||||
use codex_feedback::CodexFeedback;
|
||||
@@ -285,12 +286,17 @@ impl MessageProcessor {
|
||||
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
// The thread store is intentionally process-scoped. Config reloads can
|
||||
// affect per-thread behavior, but they must not move newly started,
|
||||
// resumed, or forked threads to a different persistence backend/root.
|
||||
let thread_store = thread_store_from_config(config.as_ref());
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.as_ref(),
|
||||
auth_manager.clone(),
|
||||
session_source,
|
||||
environment_manager,
|
||||
Some(analytics_events_client.clone()),
|
||||
Arc::clone(&thread_store),
|
||||
));
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
@@ -304,6 +310,7 @@ impl MessageProcessor {
|
||||
arg0_paths,
|
||||
config: Arc::clone(&config),
|
||||
config_manager: config_manager.clone(),
|
||||
thread_store,
|
||||
feedback,
|
||||
log_db,
|
||||
});
|
||||
@@ -314,7 +321,7 @@ impl MessageProcessor {
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_plugin_startup_tasks_for_config(
|
||||
&config,
|
||||
&config.plugins_config_input(),
|
||||
auth_manager.clone(),
|
||||
Some(on_effective_plugins_changed),
|
||||
);
|
||||
@@ -1212,18 +1219,13 @@ impl MessageProcessor {
|
||||
params: ExternalAgentConfigImportParams,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items);
|
||||
let has_migration_items = !params.migration_items.is_empty();
|
||||
let has_plugin_imports = params.migration_items.iter().any(|item| {
|
||||
matches!(
|
||||
item.item_type,
|
||||
ExternalAgentConfigMigrationItemType::Plugins
|
||||
)
|
||||
});
|
||||
let has_session_imports = params.migration_items.iter().any(|item| {
|
||||
matches!(
|
||||
item.item_type,
|
||||
ExternalAgentConfigMigrationItemType::Sessions
|
||||
)
|
||||
});
|
||||
let pending_session_imports = self
|
||||
.external_agent_config_api
|
||||
.validate_pending_session_imports(¶ms)?;
|
||||
@@ -1235,11 +1237,13 @@ impl MessageProcessor {
|
||||
.send_response(request_id, ExternalAgentConfigImportResponse {})
|
||||
.await;
|
||||
|
||||
if !has_plugin_imports && !has_session_imports {
|
||||
if !has_migration_items {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if pending_plugin_imports.is_empty() && pending_session_imports.is_empty() {
|
||||
let has_background_imports =
|
||||
!pending_plugin_imports.is_empty() || !pending_session_imports.is_empty();
|
||||
if !has_background_imports {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
|
||||
ExternalAgentConfigImportCompletedNotification {},
|
||||
|
||||
@@ -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;
|
||||
@@ -121,5 +122,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
|
||||
|
||||
@@ -59,6 +59,7 @@ use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginListParams;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
@@ -133,6 +134,13 @@ impl McpProcess {
|
||||
Self::new_with_env_and_args(codex_home, &[], &[]).await
|
||||
}
|
||||
|
||||
pub async fn new_with_env_and_plugin_startup_tasks(
|
||||
codex_home: &Path,
|
||||
env_overrides: &[(&str, Option<&str>)],
|
||||
) -> anyhow::Result<Self> {
|
||||
Self::new_with_env_and_args(codex_home, env_overrides, &[]).await
|
||||
}
|
||||
|
||||
pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result<Self> {
|
||||
let mut all_args = vec![DISABLE_PLUGIN_STARTUP_TASKS_ARG];
|
||||
all_args.extend_from_slice(args);
|
||||
@@ -653,6 +661,15 @@ impl McpProcess {
|
||||
self.send_request("plugin/read", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/skill/read` JSON-RPC request.
|
||||
pub async fn send_plugin_skill_read_request(
|
||||
&mut self,
|
||||
params: PluginSkillReadParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("plugin/skill/read", params).await
|
||||
}
|
||||
|
||||
/// Send an `mcpServerStatus/list` JSON-RPC request.
|
||||
pub async fn send_list_mcp_server_status_request(
|
||||
&mut self,
|
||||
|
||||
@@ -32,6 +32,45 @@ use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let home_dir = codex_home.path().display().to_string();
|
||||
let mut mcp =
|
||||
McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"externalAgentConfig/import",
|
||||
Some(serde_json::json!({
|
||||
"migrationItems": [{
|
||||
"itemType": "CONFIG",
|
||||
"description": "Import config",
|
||||
"cwd": null
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: ExternalAgentConfigImportResponse = to_response(response)?;
|
||||
assert_eq!(response, ExternalAgentConfigImportResponse {});
|
||||
let notification = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_agent_config_import_sends_completion_notification_for_local_plugins() -> Result<()>
|
||||
{
|
||||
|
||||
@@ -63,7 +63,7 @@ fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> R
|
||||
r#"[features]
|
||||
plugins = true
|
||||
plugin_hooks = true
|
||||
codex_hooks = true
|
||||
hooks = true
|
||||
|
||||
[plugins."demo@test"]
|
||||
enabled = true
|
||||
@@ -230,7 +230,7 @@ async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()>
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
codex_hooks = false
|
||||
hooks = false
|
||||
"#,
|
||||
)?;
|
||||
std::fs::create_dir_all(workspace.path().join(".git"))?;
|
||||
@@ -238,7 +238,7 @@ codex_hooks = false
|
||||
std::fs::write(
|
||||
workspace.path().join(".codex/config.toml"),
|
||||
r#"[features]
|
||||
codex_hooks = true
|
||||
hooks = true
|
||||
|
||||
[hooks]
|
||||
|
||||
|
||||
@@ -5,16 +5,25 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_final_assistant_message_sse_response;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml;
|
||||
use axum::Router;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::McpServerToolCallResponse;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
@@ -42,6 +51,7 @@ use tokio::time::timeout;
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_SERVER_NAME: &str = "tool_server";
|
||||
const TEST_TOOL_NAME: &str = "echo_tool";
|
||||
const LARGE_RESPONSE_MESSAGE: &str = "large";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_server_tool_call_returns_tool_result() -> Result<()> {
|
||||
@@ -161,6 +171,137 @@ async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> {
|
||||
let call_id = "call-large-mcp";
|
||||
let namespace = format!("mcp__{TEST_SERVER_NAME}__");
|
||||
let responses = vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call_with_namespace(
|
||||
call_id,
|
||||
&namespace,
|
||||
TEST_TOOL_NAME,
|
||||
&serde_json::to_string(&json!({
|
||||
"message": LARGE_RESPONSE_MESSAGE,
|
||||
}))?,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
create_final_assistant_message_sse_response("done")?,
|
||||
];
|
||||
let responses_server = create_mock_responses_server_sequence(responses).await;
|
||||
let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&responses_server.uri(),
|
||||
&BTreeMap::new(),
|
||||
/*auto_compact_limit*/ 1_000_000,
|
||||
/*requires_openai_auth*/ None,
|
||||
"mock_provider",
|
||||
"compact",
|
||||
)?;
|
||||
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let mut config_toml = std::fs::read_to_string(&config_path)?;
|
||||
config_toml.push_str(&format!(
|
||||
r#"
|
||||
[mcp_servers.{TEST_SERVER_NAME}]
|
||||
url = "{mcp_server_url}/mcp"
|
||||
"#
|
||||
));
|
||||
std::fs::write(config_path, config_toml)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
||||
|
||||
let turn_start_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Call the large MCP tool".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn, .. } = to_response(turn_start_resp)?;
|
||||
|
||||
let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?;
|
||||
assert_eq!(completed.turn_id, turn.id);
|
||||
|
||||
let ThreadItem::McpToolCall {
|
||||
id,
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
result: Some(result),
|
||||
error,
|
||||
..
|
||||
} = completed.item
|
||||
else {
|
||||
panic!("expected completed MCP tool call item");
|
||||
};
|
||||
assert_eq!(id, call_id);
|
||||
assert_eq!(server, TEST_SERVER_NAME);
|
||||
assert_eq!(tool, TEST_TOOL_NAME);
|
||||
assert_eq!(status, McpToolCallStatus::Completed);
|
||||
assert_eq!(error, None);
|
||||
assert_eq!(result.structured_content, None);
|
||||
assert_eq!(result.meta, None);
|
||||
assert_eq!(result.content.len(), 1);
|
||||
|
||||
let text = result.content[0]
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.expect("truncated MCP event result should be represented as text content");
|
||||
assert!(text.contains("truncated"));
|
||||
assert!(text.len() < DEFAULT_OUTPUT_BYTES_CAP + 1024);
|
||||
|
||||
let serialized_item = serde_json::to_string(&ThreadItem::McpToolCall {
|
||||
id,
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
|
||||
mcp_app_resource_uri: None,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
})?;
|
||||
assert!(serialized_item.len() < DEFAULT_OUTPUT_BYTES_CAP * 2 + 2048);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
mcp_server_handle.abort();
|
||||
let _ = mcp_server_handle.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ToolAppsMcpServer;
|
||||
|
||||
@@ -224,6 +365,16 @@ impl ServerHandler for ToolAppsMcpServer {
|
||||
let mut meta = Meta::new();
|
||||
meta.0.insert("calledBy".to_string(), json!("mcp-app"));
|
||||
|
||||
if message == LARGE_RESPONSE_MESSAGE {
|
||||
let large_text = "large-mcp-content-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8);
|
||||
let mut result = CallToolResult::structured(json!({
|
||||
"large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8),
|
||||
}));
|
||||
result.content = vec![Content::text(large_text)];
|
||||
result.meta = Some(meta);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let mut result = CallToolResult::structured(json!({
|
||||
"echoed": message,
|
||||
"threadId": thread_id,
|
||||
@@ -250,3 +401,23 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> {
|
||||
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn wait_for_mcp_tool_call_completed(
|
||||
mcp: &mut McpProcess,
|
||||
call_id: &str,
|
||||
) -> Result<ItemCompletedNotification> {
|
||||
loop {
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/completed"),
|
||||
)
|
||||
.await??;
|
||||
let Some(params) = notification.params else {
|
||||
continue;
|
||||
};
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(params)?;
|
||||
if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == call_id) {
|
||||
return Ok(completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppSummary;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -407,6 +408,66 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail_with_status(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
"1.2.3",
|
||||
Some(&bundle_url),
|
||||
PluginAvailability::DisabledByAdmin,
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("disabled by admin"));
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"GET",
|
||||
"/bundles/linear.tar.gz",
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
wait_for_remote_plugin_request_count(
|
||||
&server,
|
||||
"POST",
|
||||
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
||||
/*expected_count*/ 0,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -635,22 +696,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
let payload = timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = analytics_server.received_requests().await else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
if let Some(request) = requests.iter().find(|request| {
|
||||
request.method == "POST" && request.url.path() == "/codex/analytics-events/events"
|
||||
}) {
|
||||
break request.body.clone();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload).expect("analytics payload");
|
||||
let payload = wait_for_plugin_analytics_payload(&analytics_server).await?;
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
@@ -671,6 +717,59 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await;
|
||||
mount_backend_analytics_events(&server).await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
let payload = wait_for_plugin_analytics_payload(&server).await?;
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"events": [{
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": REMOTE_PLUGIN_ID,
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "chatgpt-global",
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1150,6 +1249,37 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::
|
||||
)
|
||||
}
|
||||
|
||||
async fn mount_backend_analytics_events(server: &MockServer) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/codex/analytics-events/events"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result<serde_json::Value> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = server.received_requests().await else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
if let Some(request) = requests.iter().find(|request| {
|
||||
request.method == "POST"
|
||||
&& request
|
||||
.url
|
||||
.path()
|
||||
.ends_with("/codex/analytics-events/events")
|
||||
}) {
|
||||
return serde_json::from_slice(&request.body)
|
||||
.map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn write_remote_plugin_catalog_config(
|
||||
codex_home: &std::path::Path,
|
||||
base_url: &str,
|
||||
@@ -1203,6 +1333,27 @@ async fn mount_remote_plugin_detail(
|
||||
release_version: &str,
|
||||
bundle_download_url: Option<&str>,
|
||||
) {
|
||||
mount_remote_plugin_detail_with_status(
|
||||
server,
|
||||
remote_plugin_id,
|
||||
release_version,
|
||||
bundle_download_url,
|
||||
PluginAvailability::Available,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_with_status(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
bundle_download_url: Option<&str>,
|
||||
status: PluginAvailability,
|
||||
) {
|
||||
let status = match status {
|
||||
PluginAvailability::Available => "ENABLED",
|
||||
PluginAvailability::DisabledByAdmin => "DISABLED_BY_ADMIN",
|
||||
};
|
||||
let bundle_download_url_field = bundle_download_url
|
||||
.map(|url| format!(r#" "bundle_download_url": "{url}","#))
|
||||
.unwrap_or_default();
|
||||
@@ -1213,6 +1364,7 @@ async fn mount_remote_plugin_detail(
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "{status}",
|
||||
"release": {{
|
||||
"version": "{release_version}",
|
||||
{bundle_download_url_field}
|
||||
|
||||
@@ -19,6 +19,8 @@ use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -33,6 +35,8 @@ use wiremock::matchers::query_param;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
|
||||
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
||||
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
@@ -240,6 +244,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
@@ -523,6 +528,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(codex_app_server_protocol::PluginInterface {
|
||||
display_name: Some("Valid Plugin".to_string()),
|
||||
short_description: None,
|
||||
@@ -555,6 +561,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
@@ -1129,6 +1136,135 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
"linear",
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
let global_installed_body =
|
||||
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
|
||||
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains("linear@chatgpt-global"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?;
|
||||
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
"linear",
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
let global_installed_body =
|
||||
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
||||
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let old_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.0.0");
|
||||
let new_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale");
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.expect("expected chatgpt-global marketplace entry");
|
||||
assert_eq!(
|
||||
remote_marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(
|
||||
"plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
true,
|
||||
true
|
||||
)]
|
||||
);
|
||||
|
||||
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
|
||||
wait_for_path_missing(&old_path).await?;
|
||||
wait_for_path_missing(&stale_path).await?;
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains("linear@chatgpt-global"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1154,6 +1290,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
@@ -1188,6 +1325,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
@@ -1281,6 +1419,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
|
||||
assert_eq!(remote_marketplace.plugins[0].installed, true);
|
||||
assert_eq!(remote_marketplace.plugins[0].enabled, true);
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0].availability,
|
||||
codex_app_server_protocol::PluginAvailability::Available
|
||||
);
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0]
|
||||
.interface
|
||||
@@ -1292,6 +1434,138 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let global_directory_body = r#"{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "DISABLED_BY_ADMIN",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {},
|
||||
"skills": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
let global_installed_body = r#"{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "DISABLED_BY_ADMIN",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {},
|
||||
"skills": []
|
||||
},
|
||||
"enabled": true,
|
||||
"disabled_skill_names": []
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
let empty_page_body = r#"{
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
|
||||
for (scope, body) in [
|
||||
("GLOBAL", global_directory_body),
|
||||
("WORKSPACE", empty_page_body),
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
for (scope, body) in [
|
||||
("GLOBAL", global_installed_body),
|
||||
("WORKSPACE", empty_page_body),
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.expect("expected ChatGPT remote marketplace");
|
||||
let plugin = remote_marketplace
|
||||
.plugins
|
||||
.first()
|
||||
.expect("expected remote plugin");
|
||||
assert_eq!(plugin.installed, true);
|
||||
assert_eq!(plugin.enabled, true);
|
||||
assert_eq!(
|
||||
plugin.availability,
|
||||
codex_app_server_protocol::PluginAvailability::DisabledByAdmin
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1592,17 +1866,152 @@ async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_path_missing(path: &std::path::Path) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
if !path.exists() {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn empty_remote_installed_plugins_body() -> &'static str {
|
||||
r#"{
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_body(
|
||||
bundle_download_url: &str,
|
||||
release_version: &str,
|
||||
enabled: bool,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"plugins": [
|
||||
{{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"version": "{release_version}",
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"bundle_download_url": "{bundle_download_url}",
|
||||
"app_ids": [],
|
||||
"interface": {{}},
|
||||
"skills": []
|
||||
}},
|
||||
"enabled": {enabled},
|
||||
"disabled_skill_names": []
|
||||
}}
|
||||
],
|
||||
"pagination": {{
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}}
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_bundle(
|
||||
server: &MockServer,
|
||||
plugin_name: &str,
|
||||
body: Vec<u8>,
|
||||
) -> String {
|
||||
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
|
||||
Mock::given(method("GET"))
|
||||
.and(path(bundle_path.as_str()))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/gzip")
|
||||
.set_body_bytes(body),
|
||||
)
|
||||
.mount(server)
|
||||
.await;
|
||||
format!("{}{bundle_path}", server.uri())
|
||||
}
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
||||
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
||||
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
for (path, contents, mode) in [
|
||||
(
|
||||
".codex-plugin/plugin.json",
|
||||
manifest.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
(
|
||||
"skills/plan-work/SKILL.md",
|
||||
skill.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
] {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, path, contents)?;
|
||||
}
|
||||
Ok(tar.into_inner()?.finish()?)
|
||||
}
|
||||
|
||||
fn write_installed_plugin(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
) -> Result<()> {
|
||||
write_installed_plugin_with_version(codex_home, marketplace_name, plugin_name, "local")
|
||||
}
|
||||
|
||||
fn write_installed_plugin_with_version(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
plugin_version: &str,
|
||||
) -> Result<()> {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace_name)
|
||||
.join(plugin_name)
|
||||
.join("local/.codex-plugin");
|
||||
.join(plugin_version)
|
||||
.join(".codex-plugin");
|
||||
std::fs::create_dir_all(&plugin_root)?;
|
||||
std::fs::write(
|
||||
plugin_root.join("plugin.json"),
|
||||
|
||||
@@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSkillReadParams;
|
||||
use codex_app_server_protocol::PluginSkillReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
@@ -139,6 +141,7 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
|
||||
.message
|
||||
.contains("remote plugin read is not enabled")
|
||||
);
|
||||
assert!(err.error.message.contains("chatgpt-global"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +255,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -286,6 +289,70 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let skill_body = r##"{
|
||||
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"status": "ENABLED",
|
||||
"plugin_release_id": "release-1",
|
||||
"name": "plan-work",
|
||||
"description": "Plan work from Linear issues",
|
||||
"plugin_release_skill_id": "skill-1",
|
||||
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
|
||||
}"##;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(
|
||||
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
|
||||
))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(skill_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_skill_read_request(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginSkillReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginSkillReadResponse {
|
||||
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -412,6 +479,11 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(err.error.message.contains("invalid remote plugin id"));
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::PluginShareDeleteResponse;
|
||||
use codex_app_server_protocol::PluginShareListItem;
|
||||
use codex_app_server_protocol::PluginShareListResponse;
|
||||
use codex_app_server_protocol::PluginShareSaveResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
@@ -49,6 +50,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/public/plugins/workspace/upload-url"))
|
||||
@@ -88,11 +90,12 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"plugin/share/save",
|
||||
Some(json!({
|
||||
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
|
||||
"pluginPath": expected_plugin_path.clone(),
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
@@ -111,6 +114,62 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: Some(expected_plugin_path),
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -169,15 +228,20 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -197,6 +261,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;
|
||||
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
|
||||
@@ -226,6 +292,62 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
|
||||
let response: PluginShareDeleteResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response, PluginShareDeleteResponse {});
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/created"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [installed_remote_plugin_json("plugins_123")],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("plugin/share/list", Some(json!({})))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginShareListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
source: PluginSource::Remote,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: codex_app_server_protocol::PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
|
||||
local_plugin_path: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -249,6 +371,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
"id": plugin_id,
|
||||
"name": "demo-plugin",
|
||||
"scope": "WORKSPACE",
|
||||
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
@@ -314,6 +437,33 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf>
|
||||
Ok(plugin_path)
|
||||
}
|
||||
|
||||
fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
"not-json",
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) -> std::io::Result<()> {
|
||||
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
|
||||
local_plugin_paths_by_remote_plugin_id.insert(
|
||||
remote_plugin_id.to_string(),
|
||||
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
|
||||
);
|
||||
let contents = serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
|
||||
}))
|
||||
.map_err(std::io::Error::other)?;
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!("{contents}\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let Some(parent) = path.parent() else {
|
||||
return Err(std::io::Error::other(format!(
|
||||
|
||||
@@ -28,6 +28,8 @@ use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
@@ -136,10 +138,35 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
|
||||
})
|
||||
.await??;
|
||||
|
||||
let response = client
|
||||
.request(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(3),
|
||||
params: ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(Vec::new()),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
use_state_db_only: false,
|
||||
search_term: None,
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/list should succeed");
|
||||
let ThreadListResponse { data, .. } =
|
||||
serde_json::from_value(response).expect("thread/list response should parse");
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].id, thread.id);
|
||||
assert_eq!(data[0].path, None);
|
||||
|
||||
client.shutdown().await?;
|
||||
|
||||
let calls = thread_store.calls().await;
|
||||
assert_eq!(calls.create_thread, 1);
|
||||
assert_eq!(calls.list_threads, 1);
|
||||
assert!(
|
||||
calls.append_items > 0,
|
||||
"turn/start should append rollout items through the injected store"
|
||||
|
||||
@@ -5,6 +5,12 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::rollout_path;
|
||||
use app_test_support::test_absolute_path;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server::in_process;
|
||||
use codex_app_server::in_process::InProcessStartArgs;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -31,17 +37,39 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource as ProtocolSessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_thread_store::AppendThreadItemsParams;
|
||||
use codex_thread_store::CreateThreadParams;
|
||||
use codex_thread_store::InMemoryThreadStore;
|
||||
use codex_thread_store::ThreadEventPersistenceMode;
|
||||
use codex_thread_store::ThreadMetadataPatch;
|
||||
use codex_thread_store::ThreadPersistenceMetadata;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_thread_store::UpdateThreadMetadataParams;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
|
||||
@@ -246,6 +274,147 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000123")?;
|
||||
let store_id = Uuid::new_v4().to_string();
|
||||
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
|
||||
let store = InMemoryThreadStore::for_id(store_id.clone());
|
||||
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
||||
seed_pathless_store_thread(&store, thread_id).await?;
|
||||
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
let client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Cli.into(),
|
||||
enable_codex_api_key_env: false,
|
||||
initialize: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result = client
|
||||
.request(ClientRequest::ThreadTurnsList {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadTurnsListParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/turns/list should succeed");
|
||||
let ThreadTurnsListResponse { data, .. } = serde_json::from_value(result)?;
|
||||
|
||||
assert_eq!(turn_user_texts(&data), vec!["history from store"]);
|
||||
|
||||
client.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000124")?;
|
||||
let store_id = Uuid::new_v4().to_string();
|
||||
create_config_toml_with_thread_store(codex_home.path(), &store_id)?;
|
||||
let store = InMemoryThreadStore::for_id(store_id.clone());
|
||||
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
||||
seed_pathless_store_thread(&store, thread_id).await?;
|
||||
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
let client = in_process::start(InProcessStartArgs {
|
||||
arg0_paths: Arg0DispatchPaths::default(),
|
||||
config: Arc::new(config),
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides,
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
|
||||
feedback: CodexFeedback::new(),
|
||||
log_db: None,
|
||||
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
|
||||
config_warnings: Vec::new(),
|
||||
session_source: SessionSource::Cli.into(),
|
||||
enable_codex_api_key_env: false,
|
||||
initialize: InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex-app-server-tests".to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result = client
|
||||
.request(ClientRequest::ThreadList {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadListParams {
|
||||
cursor: None,
|
||||
limit: Some(10),
|
||||
sort_key: None,
|
||||
sort_direction: None,
|
||||
model_providers: Some(Vec::new()),
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
use_state_db_only: false,
|
||||
search_term: None,
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/list should succeed");
|
||||
let ThreadListResponse { data, .. } = serde_json::from_value(result)?;
|
||||
|
||||
assert_eq!(data.len(), 1);
|
||||
let thread = &data[0];
|
||||
assert_eq!(thread.id, thread_id.to_string());
|
||||
assert_eq!(thread.path, None);
|
||||
assert_eq!(thread.preview, "");
|
||||
assert_eq!(thread.name.as_deref(), Some("named pathless thread"));
|
||||
|
||||
client.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_can_return_archived_threads_by_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -670,6 +839,59 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
let thread_path = thread.path.clone().expect("thread path");
|
||||
assert!(
|
||||
!thread_path.exists(),
|
||||
"fresh thread rollout should not be materialized yet"
|
||||
);
|
||||
|
||||
let read_id = mcp
|
||||
.send_thread_turns_list_request(ThreadTurnsListParams {
|
||||
thread_id: thread.id,
|
||||
cursor: None,
|
||||
limit: None,
|
||||
sort_direction: None,
|
||||
})
|
||||
.await?;
|
||||
let read_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(read_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
read_err
|
||||
.error
|
||||
.message
|
||||
.contains("thread/turns/list is unavailable before first user message"),
|
||||
"unexpected error: {}",
|
||||
read_err.error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
@@ -787,6 +1009,89 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct InMemoryThreadStoreId {
|
||||
store_id: String,
|
||||
}
|
||||
|
||||
impl Drop for InMemoryThreadStoreId {
|
||||
fn drop(&mut self) {
|
||||
InMemoryThreadStore::remove_id(&self.store_id);
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_pathless_store_thread(
|
||||
store: &InMemoryThreadStore,
|
||||
thread_id: codex_protocol::ThreadId,
|
||||
) -> Result<()> {
|
||||
store
|
||||
.create_thread(CreateThreadParams {
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
source: ProtocolSessionSource::Cli,
|
||||
base_instructions: BaseInstructions::default(),
|
||||
dynamic_tools: Vec::new(),
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Disabled,
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::default(),
|
||||
})
|
||||
.await?;
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id,
|
||||
items: store_history_items(),
|
||||
})
|
||||
.await?;
|
||||
store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some("named pathless thread".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: true,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_history_items() -> Vec<RolloutItem> {
|
||||
vec![RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
UserMessageEvent {
|
||||
message: "history from store".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
))]
|
||||
}
|
||||
|
||||
fn create_config_toml_with_thread_store(codex_home: &Path, store_id: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "http://127.0.0.1:1/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to create a config.toml pointing at the mock model server.
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
|
||||
@@ -71,6 +71,7 @@ use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs::FileTimes;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -1669,7 +1670,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let first_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
@@ -1749,24 +1750,71 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
|
||||
)
|
||||
.await??;
|
||||
|
||||
let resume_id = primary
|
||||
let other_thread_id = ThreadId::new().to_string();
|
||||
let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id);
|
||||
std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?;
|
||||
let thread_uuid = Uuid::parse_str(&thread_id)?;
|
||||
let mut stale_file = std::fs::File::create(&stale_path)?;
|
||||
let stale_meta = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": thread_uuid,
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"cwd": codex_home.path(),
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_meta}")?;
|
||||
let stale_user_event = json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": "stale history",
|
||||
"kind": "plain",
|
||||
},
|
||||
});
|
||||
writeln!(stale_file, "{stale_user_event}")?;
|
||||
|
||||
let stale_resume_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: Some(stale_path),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_err: JSONRPCError = timeout(
|
||||
let stale_resume_err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
||||
primary.read_stream_until_error_message(RequestId::Integer(stale_resume_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
resume_err.error.message.contains("mismatched path"),
|
||||
stale_resume_err.error.message.contains("stale path"),
|
||||
"unexpected resume error: {}",
|
||||
resume_err.error.message
|
||||
stale_resume_err.error.message
|
||||
);
|
||||
|
||||
let resume_by_path_id = primary
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: other_thread_id.clone(),
|
||||
path: thread.path,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_by_path_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_by_path_resp)?;
|
||||
assert_eq!(resumed.id, thread_id);
|
||||
|
||||
primary
|
||||
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
@@ -2463,7 +2511,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -2523,13 +2571,6 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
thread: resumed, ..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
assert_eq!(resumed.id, thread.id);
|
||||
let resumed_path = resumed.path.as_ref().expect("resumed thread path");
|
||||
let original_path = thread.path.as_ref().expect("original thread path");
|
||||
assert_eq!(
|
||||
normalized_existing_path(resumed_path)?,
|
||||
normalized_existing_path(original_path)?
|
||||
);
|
||||
assert_eq!(resumed.status, ThreadStatus::Idle);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
|
||||
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
|
||||
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
@@ -2198,9 +2197,8 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
let mut saw_resolved = false;
|
||||
let mut output_delta: Option<FileChangeOutputDeltaNotification> = None;
|
||||
let mut completed_file_change: Option<ThreadItem> = None;
|
||||
while !(output_delta.is_some() && completed_file_change.is_some()) {
|
||||
while completed_file_change.is_none() {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
let JSONRPCMessage::Notification(notification) = message else {
|
||||
continue;
|
||||
@@ -2217,16 +2215,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
|
||||
assert_eq!(resolved.request_id, resolved_request_id);
|
||||
saw_resolved = true;
|
||||
}
|
||||
"item/fileChange/outputDelta" => {
|
||||
assert!(saw_resolved, "serverRequest/resolved should arrive first");
|
||||
let notification: FileChangeOutputDeltaNotification = serde_json::from_value(
|
||||
notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/fileChange/outputDelta params"),
|
||||
)?;
|
||||
output_delta = Some(notification);
|
||||
}
|
||||
"item/completed" => {
|
||||
let completed: ItemCompletedNotification = serde_json::from_value(
|
||||
notification.params.clone().expect("item/completed params"),
|
||||
@@ -2239,16 +2227,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let output_delta = output_delta.expect("file change output delta should be observed");
|
||||
assert_eq!(output_delta.thread_id, thread.id);
|
||||
assert_eq!(output_delta.turn_id, turn.id);
|
||||
assert_eq!(output_delta.item_id, "patch-call");
|
||||
assert!(
|
||||
!output_delta.delta.is_empty(),
|
||||
"expected delta to be non-empty, got: {}",
|
||||
output_delta.delta
|
||||
);
|
||||
|
||||
let completed_file_change =
|
||||
completed_file_change.expect("file change completion should be observed");
|
||||
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
|
||||
@@ -3000,11 +2978,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
|
||||
)
|
||||
.await?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/completed"),
|
||||
@@ -3058,11 +3031,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
|
||||
|
||||
// If the server incorrectly emits FileChangeRequestApproval, the helper below will error
|
||||
// (it bails on unexpected JSONRPCMessage::Request), causing the test to fail.
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/completed"),
|
||||
|
||||
@@ -2,6 +2,7 @@ mod invocation;
|
||||
mod parser;
|
||||
mod seek_sequence;
|
||||
mod standalone_executable;
|
||||
mod streaming_parser;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
@@ -20,8 +21,8 @@ pub use parser::ParseError;
|
||||
use parser::ParseError::*;
|
||||
pub use parser::UpdateFileChunk;
|
||||
pub use parser::parse_patch;
|
||||
pub use parser::parse_patch_streaming;
|
||||
use similar::TextDiff;
|
||||
pub use streaming_parser::StreamingPatchParser;
|
||||
use thiserror::Error;
|
||||
|
||||
pub use invocation::maybe_parse_apply_patch_verified;
|
||||
|
||||
+117
-332
@@ -31,15 +31,15 @@ use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
|
||||
const END_PATCH_MARKER: &str = "*** End Patch";
|
||||
const ADD_FILE_MARKER: &str = "*** Add File: ";
|
||||
const DELETE_FILE_MARKER: &str = "*** Delete File: ";
|
||||
const UPDATE_FILE_MARKER: &str = "*** Update File: ";
|
||||
const MOVE_TO_MARKER: &str = "*** Move to: ";
|
||||
const EOF_MARKER: &str = "*** End of File";
|
||||
const CHANGE_CONTEXT_MARKER: &str = "@@ ";
|
||||
const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
|
||||
pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
|
||||
pub(crate) const END_PATCH_MARKER: &str = "*** End Patch";
|
||||
pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: ";
|
||||
pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: ";
|
||||
pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: ";
|
||||
pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: ";
|
||||
pub(crate) const EOF_MARKER: &str = "*** End of File";
|
||||
pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ ";
|
||||
pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
|
||||
|
||||
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
|
||||
/// gpt-4.1. While we could try to require everyone to pass in a strictness
|
||||
@@ -132,14 +132,6 @@ pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
|
||||
parse_patch_text(patch, mode)
|
||||
}
|
||||
|
||||
/// Parses streamed patch text that may not have reached `*** End Patch` yet.
|
||||
///
|
||||
/// This entry point is for progress reporting only; callers must not use its
|
||||
/// output to apply a patch.
|
||||
pub fn parse_patch_streaming(patch: &str) -> Result<ApplyPatchArgs, ParseError> {
|
||||
parse_patch_text(patch, ParseMode::Streaming)
|
||||
}
|
||||
|
||||
enum ParseMode {
|
||||
/// Parse the patch text argument as is.
|
||||
Strict,
|
||||
@@ -177,12 +169,6 @@ enum ParseMode {
|
||||
/// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,
|
||||
/// trim() the result, and treat what is left as the patch text.
|
||||
Lenient,
|
||||
|
||||
/// Parse partial patch text for progress reporting while the model is
|
||||
/// still streaming tool input. This mode requires a begin marker but does
|
||||
/// not require an end marker, and its output must not be used to apply a
|
||||
/// patch.
|
||||
Streaming,
|
||||
}
|
||||
|
||||
fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> {
|
||||
@@ -190,15 +176,13 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, Pars
|
||||
let (patch_lines, hunk_lines) = match mode {
|
||||
ParseMode::Strict => check_patch_boundaries_strict(&lines)?,
|
||||
ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?,
|
||||
ParseMode::Streaming => check_patch_boundaries_streaming(&lines)?,
|
||||
};
|
||||
|
||||
let mut hunks: Vec<Hunk> = Vec::new();
|
||||
let mut remaining_lines = hunk_lines;
|
||||
let mut line_number = 2;
|
||||
let allow_incomplete = matches!(mode, ParseMode::Streaming);
|
||||
while !remaining_lines.is_empty() {
|
||||
let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number, allow_incomplete)?;
|
||||
let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?;
|
||||
hunks.push(hunk);
|
||||
line_number += hunk_lines;
|
||||
remaining_lines = &remaining_lines[hunk_lines..]
|
||||
@@ -211,25 +195,6 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, Pars
|
||||
})
|
||||
}
|
||||
|
||||
fn check_patch_boundaries_streaming<'a>(
|
||||
original_lines: &'a [&'a str],
|
||||
) -> Result<(&'a [&'a str], &'a [&'a str]), ParseError> {
|
||||
match original_lines {
|
||||
[first, ..] if first.trim() == BEGIN_PATCH_MARKER => {
|
||||
let body_lines = if original_lines
|
||||
.last()
|
||||
.is_some_and(|line| line.trim() == END_PATCH_MARKER)
|
||||
{
|
||||
&original_lines[1..original_lines.len() - 1]
|
||||
} else {
|
||||
&original_lines[1..]
|
||||
};
|
||||
Ok((original_lines, body_lines))
|
||||
}
|
||||
_ => check_patch_boundaries_strict(original_lines),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the start and end lines of the patch text for `apply_patch`,
|
||||
/// returning an error if they do not match the expected markers.
|
||||
fn check_patch_boundaries_strict<'a>(
|
||||
@@ -297,15 +262,9 @@ fn check_start_and_end_lines_strict(
|
||||
|
||||
/// Attempts to parse a single hunk from the start of lines.
|
||||
/// Returns the parsed hunk and the number of lines parsed (or a ParseError).
|
||||
fn parse_one_hunk(
|
||||
lines: &[&str],
|
||||
line_number: usize,
|
||||
allow_incomplete: bool,
|
||||
) -> Result<(Hunk, usize), ParseError> {
|
||||
// Be tolerant of case mismatches and extra padding around marker strings.
|
||||
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
|
||||
let first_line = lines[0].trim();
|
||||
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
|
||||
// Add File
|
||||
let mut contents = String::new();
|
||||
let mut parsed_lines = 1;
|
||||
for add_line in &lines[1..] {
|
||||
@@ -325,7 +284,6 @@ fn parse_one_hunk(
|
||||
parsed_lines,
|
||||
));
|
||||
} else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
|
||||
// Delete File
|
||||
return Ok((
|
||||
DeleteFile {
|
||||
path: PathBuf::from(path),
|
||||
@@ -333,11 +291,8 @@ fn parse_one_hunk(
|
||||
1,
|
||||
));
|
||||
} else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
|
||||
// Update File
|
||||
let mut remaining_lines = &lines[1..];
|
||||
let mut parsed_lines = 1;
|
||||
|
||||
// Optional: move file line
|
||||
let move_path = remaining_lines
|
||||
.first()
|
||||
.and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
|
||||
@@ -348,9 +303,7 @@ fn parse_one_hunk(
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
// NOTE: we need to know to stop once we reach the next special marker header.
|
||||
while !remaining_lines.is_empty() {
|
||||
// Skip over any completely blank lines that may separate chunks.
|
||||
if remaining_lines[0].trim().is_empty() {
|
||||
parsed_lines += 1;
|
||||
remaining_lines = &remaining_lines[1..];
|
||||
@@ -361,22 +314,11 @@ fn parse_one_hunk(
|
||||
break;
|
||||
}
|
||||
|
||||
if allow_incomplete && remaining_lines[0] == "@" {
|
||||
break;
|
||||
}
|
||||
|
||||
let parsed_chunk = parse_update_file_chunk(
|
||||
let (chunk, chunk_lines) = parse_update_file_chunk(
|
||||
remaining_lines,
|
||||
line_number + parsed_lines,
|
||||
chunks.is_empty(),
|
||||
);
|
||||
let (chunk, chunk_lines) = match parsed_chunk {
|
||||
Ok(parsed) => parsed,
|
||||
Err(InvalidHunkError { .. }) if allow_incomplete && !chunks.is_empty() => {
|
||||
break;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
)?;
|
||||
chunks.push(chunk);
|
||||
parsed_lines += chunk_lines;
|
||||
remaining_lines = &remaining_lines[chunk_lines..]
|
||||
@@ -384,7 +326,10 @@ fn parse_one_hunk(
|
||||
|
||||
if chunks.is_empty() {
|
||||
return Err(InvalidHunkError {
|
||||
message: format!("Update file hunk for path '{path}' is empty"),
|
||||
message: format!(
|
||||
"Update file hunk for path '{}' is empty",
|
||||
Path::new(path).display()
|
||||
),
|
||||
line_number,
|
||||
});
|
||||
}
|
||||
@@ -418,8 +363,6 @@ fn parse_update_file_chunk(
|
||||
line_number,
|
||||
});
|
||||
}
|
||||
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
|
||||
// allow treating the chunk as starting directly with diff lines.
|
||||
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
|
||||
(None, 1)
|
||||
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
|
||||
@@ -501,162 +444,113 @@ fn parse_update_file_chunk(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_patch_streaming() {
|
||||
fn test_parse_one_hunk() {
|
||||
assert_eq!(
|
||||
parse_patch_streaming("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: vec![AddFile {
|
||||
path: PathBuf::from("src/hello.txt"),
|
||||
contents: "hello\nwor\n".to_string(),
|
||||
}],
|
||||
patch: "*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor".to_string(),
|
||||
workdir: None,
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_patch_streaming(
|
||||
"*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new",
|
||||
),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: vec![UpdateFile {
|
||||
path: PathBuf::from("src/old.rs"),
|
||||
move_path: Some(PathBuf::from("src/new.rs")),
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}],
|
||||
patch: "*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new".to_string(),
|
||||
workdir: None,
|
||||
})
|
||||
);
|
||||
|
||||
assert!(
|
||||
parse_patch_text(
|
||||
"*** Begin Patch\n*** Delete File: gone.txt",
|
||||
ParseMode::Streaming
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
parse_patch_text(
|
||||
"*** Begin Patch\n*** Delete File: gone.txt",
|
||||
ParseMode::Strict
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_patch_streaming(
|
||||
"*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
|
||||
),
|
||||
Ok(ApplyPatchArgs {
|
||||
hunks: vec![
|
||||
AddFile {
|
||||
path: PathBuf::from("src/one.txt"),
|
||||
contents: "one\n".to_string(),
|
||||
},
|
||||
DeleteFile {
|
||||
path: PathBuf::from("src/two.txt"),
|
||||
},
|
||||
],
|
||||
patch: "*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt"
|
||||
.to_string(),
|
||||
workdir: None,
|
||||
parse_one_hunk(&["bad"], /*line_number*/ 234),
|
||||
Err(InvalidHunkError {
|
||||
message: "'bad' is not a valid hunk header. \
|
||||
Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
|
||||
line_number: 234
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_patch_streaming_large_patch_by_character() {
|
||||
let patch = "\
|
||||
*** Begin Patch
|
||||
*** Add File: docs/release-notes.md
|
||||
+# Release notes
|
||||
+
|
||||
+## CLI
|
||||
+- Surface apply_patch progress while arguments stream.
|
||||
+- Keep final patch application gated on the completed tool call.
|
||||
+- Include file summaries in the progress event payload.
|
||||
*** Update File: src/config.rs
|
||||
@@ impl Config
|
||||
- pub apply_patch_progress: bool,
|
||||
+ pub stream_apply_patch_progress: bool,
|
||||
pub include_diagnostics: bool,
|
||||
@@ fn default_progress_interval()
|
||||
- Duration::from_millis(500)
|
||||
+ Duration::from_millis(250)
|
||||
*** Delete File: src/legacy_patch_progress.rs
|
||||
*** Update File: crates/cli/src/main.rs
|
||||
*** Move to: crates/cli/src/bin/codex.rs
|
||||
@@ fn run()
|
||||
- let args = Args::parse();
|
||||
- dispatch(args)
|
||||
+ let cli = Cli::parse();
|
||||
+ dispatch(cli)
|
||||
*** Add File: tests/fixtures/apply_patch_progress.json
|
||||
+{
|
||||
+ \"type\": \"apply_patch_progress\",
|
||||
+ \"hunks\": [
|
||||
+ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
|
||||
+ { \"operation\": \"update\", \"path\": \"src/config.rs\" }
|
||||
+ ]
|
||||
+}
|
||||
*** Update File: README.md
|
||||
@@ Development workflow
|
||||
Build the Rust workspace before opening a pull request.
|
||||
+When touching streamed tool calls, include parser coverage for partial input.
|
||||
+Prefer tests that exercise the exact event payload shape.
|
||||
*** Delete File: docs/old-apply-patch-progress.md
|
||||
*** End Patch";
|
||||
|
||||
let mut max_hunk_count = 0;
|
||||
let mut saw_hunk_counts = Vec::new();
|
||||
for i in 1..=patch.len() {
|
||||
let partial = &patch[..i];
|
||||
if let Ok(parsed) = parse_patch_streaming(partial) {
|
||||
let hunk_count = parsed.hunks.len();
|
||||
assert!(
|
||||
hunk_count >= max_hunk_count,
|
||||
"hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count} for {partial:?}",
|
||||
);
|
||||
if hunk_count > max_hunk_count {
|
||||
saw_hunk_counts.push(hunk_count);
|
||||
max_hunk_count = hunk_count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
|
||||
let parsed = parse_patch_streaming(patch).unwrap();
|
||||
assert_eq!(parsed.hunks.len(), 7);
|
||||
fn test_update_file_chunk() {
|
||||
assert_eq!(
|
||||
parsed
|
||||
.hunks
|
||||
.iter()
|
||||
.map(|hunk| match hunk {
|
||||
AddFile { .. } => "add",
|
||||
DeleteFile { .. } => "delete",
|
||||
UpdateFile {
|
||||
move_path: Some(_), ..
|
||||
} => "move-update",
|
||||
UpdateFile {
|
||||
move_path: None, ..
|
||||
} => "update",
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"add",
|
||||
"update",
|
||||
"delete",
|
||||
"move-update",
|
||||
"add",
|
||||
"update",
|
||||
"delete"
|
||||
]
|
||||
parse_update_file_chunk(
|
||||
&["bad"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
|
||||
.to_string(),
|
||||
line_number: 123
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@", "bad"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Unexpected line found in update hunk: 'bad'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@", "*** End of File"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&[
|
||||
"@@ change_context",
|
||||
"",
|
||||
" context",
|
||||
"-remove",
|
||||
"+add",
|
||||
" context2",
|
||||
"*** End Patch",
|
||||
],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Ok((
|
||||
UpdateFileChunk {
|
||||
change_context: Some("change_context".to_string()),
|
||||
old_lines: vec![
|
||||
String::new(),
|
||||
"context".to_string(),
|
||||
"remove".to_string(),
|
||||
"context2".to_string(),
|
||||
],
|
||||
new_lines: vec![
|
||||
String::new(),
|
||||
"context".to_string(),
|
||||
"add".to_string(),
|
||||
"context2".to_string(),
|
||||
],
|
||||
is_end_of_file: false,
|
||||
},
|
||||
6,
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@", "+line", "*** End of File"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false,
|
||||
),
|
||||
Ok((
|
||||
UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: vec!["line".to_string()],
|
||||
is_end_of_file: true,
|
||||
},
|
||||
3,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -997,112 +891,3 @@ fn test_parse_patch_lenient() {
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_one_hunk() {
|
||||
assert_eq!(
|
||||
parse_one_hunk(&["bad"], /*line_number*/ 234, /*allow_incomplete*/ false),
|
||||
Err(InvalidHunkError {
|
||||
message: "'bad' is not a valid hunk header. \
|
||||
Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'".to_string(),
|
||||
line_number: 234
|
||||
})
|
||||
);
|
||||
// Other edge cases are already covered by tests above/below.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_file_chunk() {
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["bad"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
|
||||
.to_string(),
|
||||
line_number: 123
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(&["@@", "bad"], /*line_number*/ 123, /*allow_missing_context*/ false),
|
||||
Err(InvalidHunkError {
|
||||
message: "Unexpected line found in update hunk: 'bad'. \
|
||||
Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@", "*** End of File"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 124
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&[
|
||||
"@@ change_context",
|
||||
"",
|
||||
" context",
|
||||
"-remove",
|
||||
"+add",
|
||||
" context2",
|
||||
"*** End Patch",
|
||||
],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false
|
||||
),
|
||||
Ok((
|
||||
(UpdateFileChunk {
|
||||
change_context: Some("change_context".to_string()),
|
||||
old_lines: vec![
|
||||
"".to_string(),
|
||||
"context".to_string(),
|
||||
"remove".to_string(),
|
||||
"context2".to_string()
|
||||
],
|
||||
new_lines: vec![
|
||||
"".to_string(),
|
||||
"context".to_string(),
|
||||
"add".to_string(),
|
||||
"context2".to_string()
|
||||
],
|
||||
is_end_of_file: false
|
||||
}),
|
||||
6
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_update_file_chunk(
|
||||
&["@@", "+line", "*** End of File"],
|
||||
/*line_number*/ 123,
|
||||
/*allow_missing_context*/ false
|
||||
),
|
||||
Ok((
|
||||
(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec![],
|
||||
new_lines: vec!["line".to_string()],
|
||||
is_end_of_file: true
|
||||
}),
|
||||
3
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::parser::ADD_FILE_MARKER;
|
||||
use crate::parser::BEGIN_PATCH_MARKER;
|
||||
use crate::parser::CHANGE_CONTEXT_MARKER;
|
||||
use crate::parser::DELETE_FILE_MARKER;
|
||||
use crate::parser::EMPTY_CHANGE_CONTEXT_MARKER;
|
||||
use crate::parser::END_PATCH_MARKER;
|
||||
use crate::parser::EOF_MARKER;
|
||||
use crate::parser::Hunk;
|
||||
use crate::parser::MOVE_TO_MARKER;
|
||||
use crate::parser::ParseError;
|
||||
use crate::parser::UPDATE_FILE_MARKER;
|
||||
use crate::parser::UpdateFileChunk;
|
||||
|
||||
use Hunk::*;
|
||||
use ParseError::*;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct StreamingPatchParser {
|
||||
line_buffer: String,
|
||||
state: StreamingParserState,
|
||||
line_number: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct StreamingParserState {
|
||||
mode: StreamingParserMode,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
enum StreamingParserMode {
|
||||
#[default]
|
||||
NotStarted,
|
||||
StartedPatch,
|
||||
AddFile,
|
||||
DeleteFile,
|
||||
UpdateFile {
|
||||
hunk_line_number: usize,
|
||||
},
|
||||
EndedPatch,
|
||||
}
|
||||
|
||||
impl StreamingPatchParser {
|
||||
fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> {
|
||||
if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() {
|
||||
if chunks.is_empty()
|
||||
&& let StreamingParserMode::UpdateFile { hunk_line_number } = self.state.mode
|
||||
{
|
||||
return Err(InvalidHunkError {
|
||||
message: format!("Update file hunk for path '{}' is empty", path.display()),
|
||||
line_number: hunk_line_number,
|
||||
});
|
||||
}
|
||||
if chunks
|
||||
.last()
|
||||
.is_some_and(|chunk| chunk.old_lines.is_empty() && chunk.new_lines.is_empty())
|
||||
{
|
||||
if line == END_PATCH_MARKER {
|
||||
return Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: self.line_number,
|
||||
});
|
||||
}
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_hunk_headers_and_end_patch(&mut self, trimmed: &str) -> Result<bool, ParseError> {
|
||||
if trimmed == END_PATCH_MARKER {
|
||||
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
||||
self.state.mode = StreamingParserMode::EndedPatch;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(path) = trimmed.strip_prefix(ADD_FILE_MARKER) {
|
||||
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
||||
self.state.hunks.push(AddFile {
|
||||
path: PathBuf::from(path),
|
||||
contents: String::new(),
|
||||
});
|
||||
self.state.mode = StreamingParserMode::AddFile;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(path) = trimmed.strip_prefix(DELETE_FILE_MARKER) {
|
||||
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
||||
self.state.hunks.push(DeleteFile {
|
||||
path: PathBuf::from(path),
|
||||
});
|
||||
self.state.mode = StreamingParserMode::DeleteFile;
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(path) = trimmed.strip_prefix(UPDATE_FILE_MARKER) {
|
||||
self.ensure_update_hunk_is_not_empty(trimmed)?;
|
||||
self.state.hunks.push(UpdateFile {
|
||||
path: PathBuf::from(path),
|
||||
move_path: None,
|
||||
chunks: Vec::new(),
|
||||
});
|
||||
self.state.mode = StreamingParserMode::UpdateFile {
|
||||
hunk_line_number: self.line_number,
|
||||
};
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn push_delta(&mut self, delta: &str) -> Result<Vec<Hunk>, ParseError> {
|
||||
for ch in delta.chars() {
|
||||
if ch == '\n' {
|
||||
let mut line = std::mem::take(&mut self.line_buffer);
|
||||
line.truncate(line.strip_suffix('\r').map_or(line.len(), str::len));
|
||||
self.line_number += 1;
|
||||
self.process_line(&line)?;
|
||||
} else {
|
||||
self.line_buffer.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.state.hunks.clone())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<Hunk>, ParseError> {
|
||||
if !self.line_buffer.is_empty() {
|
||||
let line = std::mem::take(&mut self.line_buffer);
|
||||
self.line_number += 1;
|
||||
if line.trim() == END_PATCH_MARKER {
|
||||
self.ensure_update_hunk_is_not_empty(line.trim())?;
|
||||
self.state.mode = StreamingParserMode::EndedPatch;
|
||||
} else {
|
||||
self.process_line(&line)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !matches!(self.state.mode, StreamingParserMode::EndedPatch) {
|
||||
return Err(InvalidPatchError(
|
||||
"The last line of the patch must be '*** End Patch'".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(self.state.hunks.clone())
|
||||
}
|
||||
|
||||
fn process_line(&mut self, line: &str) -> Result<(), ParseError> {
|
||||
let trimmed = line.trim();
|
||||
match self.state.mode.clone() {
|
||||
StreamingParserMode::NotStarted => {
|
||||
if trimmed == BEGIN_PATCH_MARKER {
|
||||
self.state.mode = StreamingParserMode::StartedPatch;
|
||||
return Ok(());
|
||||
}
|
||||
Err(InvalidPatchError(
|
||||
"The first line of the patch must be '*** Begin Patch'".to_string(),
|
||||
))
|
||||
}
|
||||
StreamingParserMode::StartedPatch => {
|
||||
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
})
|
||||
}
|
||||
StreamingParserMode::AddFile => {
|
||||
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(line_to_add) = line.strip_prefix('+')
|
||||
&& let Some(AddFile { contents, .. }) = self.state.hunks.last_mut()
|
||||
{
|
||||
contents.push_str(line_to_add);
|
||||
contents.push('\n');
|
||||
return Ok(());
|
||||
}
|
||||
Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
})
|
||||
}
|
||||
StreamingParserMode::DeleteFile => {
|
||||
if self.handle_hunk_headers_and_end_patch(trimmed)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"'{trimmed}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
})
|
||||
}
|
||||
StreamingParserMode::UpdateFile { hunk_line_number } => {
|
||||
let update_line = line.trim_end();
|
||||
if self.handle_hunk_headers_and_end_patch(update_line)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(UpdateFile {
|
||||
move_path, chunks, ..
|
||||
}) = self.state.hunks.last_mut()
|
||||
{
|
||||
if chunks.is_empty()
|
||||
&& move_path.is_none()
|
||||
&& let Some(move_to_path) = update_line.strip_prefix(MOVE_TO_MARKER)
|
||||
{
|
||||
*move_path = Some(PathBuf::from(move_to_path));
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if (update_line == EMPTY_CHANGE_CONTEXT_MARKER
|
||||
|| update_line.starts_with(CHANGE_CONTEXT_MARKER))
|
||||
&& chunks.last().is_some_and(|chunk| {
|
||||
chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
|
||||
})
|
||||
{
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
});
|
||||
}
|
||||
|
||||
if update_line == EMPTY_CHANGE_CONTEXT_MARKER {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: Some(change_context.to_string()),
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if update_line == EOF_MARKER {
|
||||
if chunks.last().is_some_and(|chunk| {
|
||||
chunk.old_lines.is_empty() && chunk.new_lines.is_empty()
|
||||
}) {
|
||||
return Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: self.line_number,
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
chunk.is_end_of_file = true;
|
||||
}
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if line.is_empty() {
|
||||
if chunks.is_empty() {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
chunk.old_lines.push(String::new());
|
||||
chunk.new_lines.push(String::new());
|
||||
}
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(line_to_add) = line.strip_prefix(' ') {
|
||||
if chunks.is_empty() {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
chunk.old_lines.push(line_to_add.to_string());
|
||||
chunk.new_lines.push(line_to_add.to_string());
|
||||
}
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(line_to_add) = line.strip_prefix('+') {
|
||||
if chunks.is_empty() {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
chunk.new_lines.push(line_to_add.to_string());
|
||||
}
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(line_to_remove) = line.strip_prefix('-') {
|
||||
if chunks.is_empty() {
|
||||
chunks.push(UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: Vec::new(),
|
||||
new_lines: Vec::new(),
|
||||
is_end_of_file: false,
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
chunk.old_lines.push(line_to_remove.to_string());
|
||||
}
|
||||
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if chunks.last().is_some_and(|chunk| {
|
||||
!chunk.old_lines.is_empty() || !chunk.new_lines.is_empty()
|
||||
}) {
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Expected update hunk to start with a @@ context marker, got: '{line}'"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Unexpected line found in update hunk: '{line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
),
|
||||
line_number: self.line_number,
|
||||
})
|
||||
}
|
||||
StreamingParserMode::EndedPatch => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_streams_complete_lines_before_end_patch() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Add File: src/hello.txt\n+hello\n+wor"),
|
||||
Ok(vec![AddFile {
|
||||
path: PathBuf::from("src/hello.txt"),
|
||||
contents: "hello\n".to_string(),
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parser.push_delta("ld\n"),
|
||||
Ok(vec![AddFile {
|
||||
path: PathBuf::from("src/hello.txt"),
|
||||
contents: "hello\nworld\n".to_string(),
|
||||
}])
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"*** Begin Patch\n*** Update File: src/old.rs\n*** Move to: src/new.rs\n@@\n-old\n+new\n",
|
||||
),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("src/old.rs"),
|
||||
move_path: Some(PathBuf::from("src/new.rs")),
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Delete File: gone.txt"),
|
||||
Ok(Vec::new())
|
||||
);
|
||||
assert_eq!(
|
||||
parser.push_delta("\n"),
|
||||
Ok(vec![DeleteFile {
|
||||
path: PathBuf::from("gone.txt"),
|
||||
}])
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"*** Begin Patch\n*** Add File: src/one.txt\n+one\n*** Delete File: src/two.txt\n",
|
||||
),
|
||||
Ok(vec![
|
||||
AddFile {
|
||||
path: PathBuf::from("src/one.txt"),
|
||||
contents: "one\n".to_string(),
|
||||
},
|
||||
DeleteFile {
|
||||
path: PathBuf::from("src/two.txt"),
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_large_patch_split_by_character() {
|
||||
let patch = "\
|
||||
*** Begin Patch
|
||||
*** Add File: docs/release-notes.md
|
||||
+# Release notes
|
||||
+
|
||||
+## CLI
|
||||
+- Surface apply_patch progress while arguments stream.
|
||||
+- Keep final patch application gated on the completed tool call.
|
||||
+- Include file summaries in the progress event payload.
|
||||
*** Update File: src/config.rs
|
||||
@@ impl Config
|
||||
- pub apply_patch_progress: bool,
|
||||
+ pub stream_apply_patch_progress: bool,
|
||||
pub include_diagnostics: bool,
|
||||
@@ fn default_progress_interval()
|
||||
- Duration::from_millis(500)
|
||||
+ Duration::from_millis(250)
|
||||
*** Delete File: src/legacy_patch_progress.rs
|
||||
*** Update File: crates/cli/src/main.rs
|
||||
*** Move to: crates/cli/src/bin/codex.rs
|
||||
@@ fn run()
|
||||
- let args = Args::parse();
|
||||
- dispatch(args)
|
||||
+ let cli = Cli::parse();
|
||||
+ dispatch(cli)
|
||||
*** Add File: tests/fixtures/apply_patch_progress.json
|
||||
+{
|
||||
+ \"type\": \"apply_patch_progress\",
|
||||
+ \"hunks\": [
|
||||
+ { \"operation\": \"add\", \"path\": \"docs/release-notes.md\" },
|
||||
+ { \"operation\": \"update\", \"path\": \"src/config.rs\" }
|
||||
+ ]
|
||||
+}
|
||||
*** Update File: README.md
|
||||
@@ Development workflow
|
||||
Build the Rust workspace before opening a pull request.
|
||||
+When touching streamed tool calls, include parser coverage for partial input.
|
||||
+Prefer tests that exercise the exact event payload shape.
|
||||
*** Delete File: docs/old-apply-patch-progress.md
|
||||
*** End Patch";
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
let mut max_hunk_count = 0;
|
||||
let mut saw_hunk_counts = Vec::new();
|
||||
let mut hunks = Vec::new();
|
||||
for ch in patch.chars() {
|
||||
let updated_hunks = parser.push_delta(&ch.to_string()).unwrap();
|
||||
if !updated_hunks.is_empty() {
|
||||
let hunk_count = updated_hunks.len();
|
||||
assert!(
|
||||
hunk_count >= max_hunk_count,
|
||||
"hunk count should never decrease while streaming: {hunk_count} < {max_hunk_count}",
|
||||
);
|
||||
if hunk_count > max_hunk_count {
|
||||
saw_hunk_counts.push(hunk_count);
|
||||
max_hunk_count = hunk_count;
|
||||
}
|
||||
hunks = updated_hunks;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(saw_hunk_counts, vec![1, 2, 3, 4, 5, 6, 7]);
|
||||
assert_eq!(hunks.len(), 7);
|
||||
assert_eq!(
|
||||
hunks
|
||||
.iter()
|
||||
.map(|hunk| match hunk {
|
||||
AddFile { .. } => "add",
|
||||
DeleteFile { .. } => "delete",
|
||||
UpdateFile {
|
||||
move_path: Some(_), ..
|
||||
} => "move-update",
|
||||
UpdateFile {
|
||||
move_path: None, ..
|
||||
} => "update",
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"add",
|
||||
"update",
|
||||
"delete",
|
||||
"move-update",
|
||||
"add",
|
||||
"update",
|
||||
"delete"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_keeps_indented_update_markers_as_context_lines() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"\
|
||||
*** Begin Patch
|
||||
*** Update File: a.txt
|
||||
@@
|
||||
-old a
|
||||
+new a
|
||||
*** Update File: b.txt
|
||||
@@
|
||||
-old b
|
||||
+new b
|
||||
*** End Patch
|
||||
",
|
||||
),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("a.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![
|
||||
UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()],
|
||||
new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()],
|
||||
is_end_of_file: false,
|
||||
},
|
||||
UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old b".to_string()],
|
||||
new_lines: vec!["new b".to_string()],
|
||||
is_end_of_file: false,
|
||||
},
|
||||
],
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_preserves_bare_empty_update_lines() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"\
|
||||
*** Begin Patch
|
||||
*** Update File: file.txt
|
||||
@@
|
||||
context before
|
||||
|
||||
context after
|
||||
*** End Patch
|
||||
",
|
||||
),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
// The normal parser treats a bare empty line in an update hunk as an
|
||||
// empty context line. Preserve that leniency in the streaming parser.
|
||||
old_lines: vec![
|
||||
"context before".to_string(),
|
||||
String::new(),
|
||||
"context after".to_string(),
|
||||
],
|
||||
new_lines: vec![
|
||||
"context before".to_string(),
|
||||
String::new(),
|
||||
"context after".to_string(),
|
||||
],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_matches_line_ending_behavior() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n"),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n"),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old\r".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_finish_processes_final_line_without_newline() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n*** End Patch"),
|
||||
Ok(vec![AddFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
contents: "hello\n".to_string(),
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parser.finish(),
|
||||
Ok(vec![AddFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
contents: "hello\n".to_string(),
|
||||
}])
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n *** End Patch",
|
||||
),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parser.finish(),
|
||||
Ok(vec![UpdateFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
move_path: None,
|
||||
chunks: vec![UpdateFileChunk {
|
||||
change_context: None,
|
||||
old_lines: vec!["old".to_string()],
|
||||
new_lines: vec!["new".to_string()],
|
||||
is_end_of_file: false,
|
||||
}],
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_finish_requires_end_patch() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\n+hello\n"),
|
||||
Ok(vec![AddFile {
|
||||
path: PathBuf::from("file.txt"),
|
||||
contents: "hello\n".to_string(),
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
parser.finish(),
|
||||
Err(InvalidPatchError(
|
||||
"The last line of the patch must be '*** End Patch'".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_patch_parser_returns_errors() {
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("bad\n"),
|
||||
Err(InvalidPatchError(
|
||||
"The first line of the patch must be '*** Begin Patch'".to_string(),
|
||||
))
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(parser.push_delta("*** Begin Patch\n"), Ok(Vec::new()));
|
||||
assert_eq!(
|
||||
parser.push_delta("bad\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
||||
.to_string(),
|
||||
line_number: 2,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Add File: file.txt\nbad\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
||||
.to_string(),
|
||||
line_number: 3,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Delete File: file.txt\nbad\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "'bad' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'"
|
||||
.to_string(),
|
||||
line_number: 3,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n*** End Patch\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update file hunk for path 'file.txt' is empty".to_string(),
|
||||
line_number: 2,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n*** Delete File: other.txt\n",
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update file hunk for path 'old.txt' is empty".to_string(),
|
||||
line_number: 2,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End Patch\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 4,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n*** End of File\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "Update hunk does not contain any lines".to_string(),
|
||||
line_number: 4,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n@@\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "Unexpected line found in update hunk: '@@'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
.to_string(),
|
||||
line_number: 4,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\nbad\n"),
|
||||
Err(InvalidHunkError {
|
||||
message: "Expected update hunk to start with a @@ context marker, got: 'bad'"
|
||||
.to_string(),
|
||||
line_number: 5,
|
||||
})
|
||||
);
|
||||
|
||||
let mut parser = StreamingPatchParser::default();
|
||||
assert_eq!(
|
||||
parser.push_delta(
|
||||
"*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n",
|
||||
),
|
||||
Err(InvalidHunkError {
|
||||
message: "Unexpected line found in update hunk: '*** Update File: other.txt'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
.to_string(),
|
||||
line_number: 4,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ clap = { workspace = true, features = ["derive"] }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-core-plugins = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -16,11 +16,11 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_o
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
|
||||
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_plugin::AppConnectorId;
|
||||
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -135,9 +135,10 @@ fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsC
|
||||
)
|
||||
}
|
||||
|
||||
async fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
|
||||
async fn plugin_apps_for_config(config: &Config) -> Vec<AppConnectorId> {
|
||||
let plugins_input = config.plugins_config_input();
|
||||
PluginsManager::new(config.codex_home.to_path_buf())
|
||||
.plugins_for_config(config)
|
||||
.plugins_for_config(&plugins_input)
|
||||
.await
|
||||
.effective_apps()
|
||||
}
|
||||
@@ -188,7 +189,7 @@ pub fn merge_connectors_with_accessible(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_connectors::metadata::connector_install_url;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn app(id: &str) -> AppInfo {
|
||||
|
||||
@@ -533,10 +533,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
|
||||
let mut lines = Vec::new();
|
||||
if !token_usage.is_zero() {
|
||||
lines.push(format!(
|
||||
"{}",
|
||||
codex_protocol::protocol::FinalOutput::from(token_usage)
|
||||
));
|
||||
lines.push(token_usage.to_string());
|
||||
}
|
||||
|
||||
if let Some(resume_cmd) =
|
||||
@@ -1688,7 +1685,7 @@ mod tests {
|
||||
use super::*;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_tui::TokenUsage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
|
||||
|
||||
@@ -4,8 +4,8 @@ use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::PluginMarketplaceUpgradeOutcome;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
|
||||
use codex_core_plugins::marketplace_add::add_marketplace;
|
||||
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
|
||||
@@ -128,8 +128,9 @@ async fn run_upgrade(
|
||||
.context("failed to load configuration")?;
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let manager = PluginsManager::new(codex_home.to_path_buf());
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let outcome = manager
|
||||
.upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref())
|
||||
.upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
print_upgrade_outcome(&outcome, marketplace_name.as_deref())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use codex_core::config::Config;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_global_mcp_servers;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_mcp::McpOAuthLoginSupport;
|
||||
use codex_mcp::ResolvedMcpOAuthScopes;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
use crate::mcp::McpPermissionPromptAutoApproveContext;
|
||||
use crate::mcp::mcp_permission_prompt_is_auto_approved;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -87,8 +88,11 @@ impl ElicitationRequestManager {
|
||||
.lock()
|
||||
.map(|profile| profile.clone())
|
||||
.unwrap_or_default();
|
||||
if mcp_permission_prompt_is_auto_approved(approval_policy, &permission_profile)
|
||||
&& can_auto_accept_elicitation(&elicitation)
|
||||
if mcp_permission_prompt_is_auto_approved(
|
||||
approval_policy,
|
||||
&permission_profile,
|
||||
McpPermissionPromptAutoApproveContext::default(),
|
||||
) && can_auto_accept_elicitation(&elicitation)
|
||||
{
|
||||
return Ok(ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
|
||||
@@ -34,6 +34,7 @@ pub use mcp::resolve_oauth_scopes;
|
||||
pub use mcp::should_retry_without_scopes;
|
||||
|
||||
pub use codex_apps::filter_non_codex_apps_mcp_tools_only;
|
||||
pub use mcp::McpPermissionPromptAutoApproveContext;
|
||||
pub use mcp::mcp_permission_prompt_is_auto_approved;
|
||||
pub use mcp::qualified_mcp_tool_name_prefix;
|
||||
pub use tools::declared_openai_file_input_param_names;
|
||||
|
||||
@@ -20,6 +20,8 @@ use async_channel::unbounded;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
@@ -67,7 +69,17 @@ pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
|
||||
pub fn mcp_permission_prompt_is_auto_approved(
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: &PermissionProfile,
|
||||
context: McpPermissionPromptAutoApproveContext,
|
||||
) -> bool {
|
||||
if matches!(
|
||||
approval_policy,
|
||||
AskForApproval::OnRequest | AskForApproval::Granular(_)
|
||||
) && context.approvals_reviewer == Some(ApprovalsReviewer::AutoReview)
|
||||
&& context.tool_approval_mode == Some(AppToolApproval::Approve)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if approval_policy != AskForApproval::Never {
|
||||
return false;
|
||||
}
|
||||
@@ -80,6 +92,12 @@ pub fn mcp_permission_prompt_is_auto_approved(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct McpPermissionPromptAutoApproveContext {
|
||||
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
pub tool_approval_mode: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
/// MCP runtime settings derived from `codex_core::config::Config`.
|
||||
///
|
||||
/// This struct should contain only long-lived configuration values that the
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
@@ -7,6 +9,7 @@ use codex_protocol::models::ManagedFileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -45,6 +48,7 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() {
|
||||
file_system: ManagedFileSystemPermissions::Unrestricted,
|
||||
network: NetworkSandboxPolicy::Enabled,
|
||||
},
|
||||
McpPermissionPromptAutoApproveContext::default(),
|
||||
));
|
||||
assert!(mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::Never,
|
||||
@@ -52,10 +56,12 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() {
|
||||
file_system: ManagedFileSystemPermissions::Unrestricted,
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
},
|
||||
McpPermissionPromptAutoApproveContext::default(),
|
||||
));
|
||||
assert!(!mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::Never,
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext::default(),
|
||||
));
|
||||
assert!(!mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::OnRequest,
|
||||
@@ -63,6 +69,57 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() {
|
||||
file_system: ManagedFileSystemPermissions::Unrestricted,
|
||||
network: NetworkSandboxPolicy::Enabled,
|
||||
},
|
||||
McpPermissionPromptAutoApproveContext::default(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_prompt_auto_approval_honors_auto_review_approved_tools() {
|
||||
assert!(mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::OnRequest,
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
tool_approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
));
|
||||
assert!(mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}),
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
tool_approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
));
|
||||
assert!(!mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::OnRequest,
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
tool_approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
));
|
||||
assert!(!mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::OnFailure,
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
tool_approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
));
|
||||
assert!(!mcp_permission_prompt_is_auto_approved(
|
||||
AskForApproval::UnlessTrusted,
|
||||
&PermissionProfile::read_only(),
|
||||
McpPermissionPromptAutoApproveContext {
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
tool_approval_mode: Some(AppToolApproval::Approve),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,6 @@ pub enum ThreadStoreToml {
|
||||
Remote {
|
||||
endpoint: String,
|
||||
},
|
||||
#[cfg(debug_assertions)]
|
||||
#[schemars(skip)]
|
||||
InMemory {
|
||||
id: String,
|
||||
|
||||
@@ -14,6 +14,7 @@ mod mcp_types;
|
||||
mod merge;
|
||||
mod overrides;
|
||||
pub mod permissions_toml;
|
||||
mod plugin_edit;
|
||||
pub mod profile_toml;
|
||||
mod project_root_markers;
|
||||
mod requirements_exec_policy;
|
||||
@@ -95,6 +96,10 @@ pub use mcp_types::McpServerTransportConfig;
|
||||
pub use mcp_types::RawMcpServerConfig;
|
||||
pub use merge::merge_toml_values;
|
||||
pub use overrides::build_cli_overrides_layer;
|
||||
pub use plugin_edit::PluginConfigEdit;
|
||||
pub use plugin_edit::apply_user_plugin_config_edits;
|
||||
pub use plugin_edit::clear_user_plugin;
|
||||
pub use plugin_edit::set_user_plugin_enabled;
|
||||
pub use project_root_markers::default_project_root_markers;
|
||||
pub use project_root_markers::project_root_markers_from_config;
|
||||
pub use requirements_exec_policy::RequirementsExecPolicy;
|
||||
|
||||
@@ -47,6 +47,21 @@ const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
|
||||
|
||||
// Project-local config comes from repository contents, so it should not get to
|
||||
// choose where a user's credentials are sent or which local commands are run.
|
||||
// These settings are still supported from user, system, managed, and runtime
|
||||
// config layers.
|
||||
const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"notify",
|
||||
"profile",
|
||||
"profiles",
|
||||
"experimental_realtime_ws_base_url",
|
||||
];
|
||||
|
||||
async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option<ConfigError> {
|
||||
typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
||||
}
|
||||
@@ -197,6 +212,7 @@ pub async fn load_config_layers_state(
|
||||
};
|
||||
layers.push(user_layer);
|
||||
|
||||
let mut startup_warnings = None;
|
||||
if let Some(cwd) = cwd {
|
||||
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in &layers {
|
||||
@@ -253,7 +269,8 @@ pub async fn load_config_layers_state(
|
||||
codex_home,
|
||||
)
|
||||
.await?;
|
||||
layers.extend(project_layers);
|
||||
layers.extend(project_layers.layers);
|
||||
startup_warnings = Some(project_layers.startup_warnings);
|
||||
}
|
||||
|
||||
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
||||
@@ -309,12 +326,16 @@ pub async fn load_config_layers_state(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ConfigLayerStack::new(
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
layers,
|
||||
config_requirements_toml.clone().try_into()?,
|
||||
config_requirements_toml.into_toml(),
|
||||
)?
|
||||
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules))
|
||||
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
|
||||
Ok(match startup_warnings {
|
||||
Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings),
|
||||
None => config_layer_stack,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
|
||||
@@ -708,6 +729,38 @@ fn project_layer_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
|
||||
let Some(table) = config.as_table_mut() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut ignored_keys = Vec::new();
|
||||
for key in PROJECT_LOCAL_CONFIG_DENYLIST {
|
||||
if table.remove(*key).is_some() {
|
||||
ignored_keys.push((*key).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
ignored_keys
|
||||
}
|
||||
|
||||
fn project_ignored_config_keys_warning(
|
||||
dot_codex_folder: &AbsolutePathBuf,
|
||||
ignored_keys: &[String],
|
||||
) -> String {
|
||||
let config_path = dot_codex_folder.join(CONFIG_TOML_FILE);
|
||||
let ignored_keys = ignored_keys.join(", ");
|
||||
format!(
|
||||
concat!(
|
||||
"Ignored unsupported project-local config keys in {config_path}: {ignored_keys}. ",
|
||||
"If you want these settings to apply, manually set them in your ",
|
||||
"user-level config.toml."
|
||||
),
|
||||
config_path = config_path.display(),
|
||||
ignored_keys = ignored_keys,
|
||||
)
|
||||
}
|
||||
|
||||
async fn project_trust_context(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
merged_config: &TomlValue,
|
||||
@@ -890,18 +943,24 @@ async fn find_project_root(
|
||||
Ok(cwd.clone())
|
||||
}
|
||||
|
||||
struct LoadedProjectLayers {
|
||||
layers: Vec<ConfigLayerEntry>,
|
||||
startup_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Return the appropriate list of layers (each with
|
||||
/// [ConfigLayerSource::Project] as the source) between `cwd` and
|
||||
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
|
||||
/// starting from folders closest to `project_root` (which is the lowest
|
||||
/// precedence) to those closest to `cwd` (which is the highest precedence).
|
||||
/// Any warnings are stack-level startup messages, not additional config layers.
|
||||
async fn load_project_layers(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root: &AbsolutePathBuf,
|
||||
trust_context: &ProjectTrustContext,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ConfigLayerEntry>> {
|
||||
) -> io::Result<LoadedProjectLayers> {
|
||||
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let codex_home_normalized =
|
||||
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
|
||||
@@ -921,6 +980,7 @@ async fn load_project_layers(
|
||||
dirs.reverse();
|
||||
|
||||
let mut layers = Vec::new();
|
||||
let mut startup_warnings = Vec::new();
|
||||
for dir in dirs {
|
||||
let dot_codex_abs = dir.join(".codex");
|
||||
if !fs
|
||||
@@ -962,8 +1022,16 @@ async fn load_project_layers(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut config = config;
|
||||
let ignored_project_config_keys = sanitize_project_config(&mut config);
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
||||
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
|
||||
startup_warnings.push(project_ignored_config_keys_warning(
|
||||
&dot_codex_abs,
|
||||
&ignored_project_config_keys,
|
||||
));
|
||||
}
|
||||
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
|
||||
layers.push(entry);
|
||||
}
|
||||
@@ -988,7 +1056,10 @@ async fn load_project_layers(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(layers)
|
||||
Ok(LoadedProjectLayers {
|
||||
layers,
|
||||
startup_warnings,
|
||||
})
|
||||
}
|
||||
/// The legacy mechanism for specifying admin-enforced configuration is to read
|
||||
/// from a file like `/etc/codex/managed_config.toml` that has the same
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_utils_path::resolve_symlink_write_paths;
|
||||
use codex_utils_path::write_atomically;
|
||||
use tokio::task;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::value;
|
||||
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginConfigEdit {
|
||||
SetEnabled { plugin_key: String, enabled: bool },
|
||||
Clear { plugin_key: String },
|
||||
}
|
||||
|
||||
pub async fn set_user_plugin_enabled(
|
||||
codex_home: &Path,
|
||||
plugin_key: String,
|
||||
enabled: bool,
|
||||
) -> std::io::Result<()> {
|
||||
apply_user_plugin_config_edits(
|
||||
codex_home,
|
||||
vec![PluginConfigEdit::SetEnabled {
|
||||
plugin_key,
|
||||
enabled,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io::Result<()> {
|
||||
apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await
|
||||
}
|
||||
|
||||
pub async fn apply_user_plugin_config_edits(
|
||||
codex_home: &Path,
|
||||
edits: Vec<PluginConfigEdit>,
|
||||
) -> std::io::Result<()> {
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
task::spawn_blocking(move || apply_user_plugin_config_edits_blocking(&codex_home, edits))
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))?
|
||||
}
|
||||
|
||||
fn apply_user_plugin_config_edits_blocking(
|
||||
codex_home: &Path,
|
||||
edits: Vec<PluginConfigEdit>,
|
||||
) -> std::io::Result<()> {
|
||||
if edits.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let write_paths = resolve_symlink_write_paths(&config_path)?;
|
||||
let mut doc = read_or_create_document(write_paths.read_path.as_deref())?;
|
||||
let mut mutated = false;
|
||||
for edit in edits {
|
||||
mutated |= match edit {
|
||||
PluginConfigEdit::SetEnabled {
|
||||
plugin_key,
|
||||
enabled,
|
||||
} => set_plugin_enabled(&mut doc, &plugin_key, enabled),
|
||||
PluginConfigEdit::Clear { plugin_key } => clear_plugin(&mut doc, &plugin_key),
|
||||
};
|
||||
}
|
||||
if !mutated {
|
||||
return Ok(());
|
||||
}
|
||||
write_atomically(&write_paths.write_path, &doc.to_string())
|
||||
}
|
||||
|
||||
fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result<DocumentMut> {
|
||||
let Some(config_path) = config_path else {
|
||||
return Ok(DocumentMut::new());
|
||||
};
|
||||
match fs::read_to_string(config_path) {
|
||||
Ok(raw) => raw
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(DocumentMut::new()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) -> bool {
|
||||
let Some(plugins) = ensure_plugins_table(doc) else {
|
||||
return false;
|
||||
};
|
||||
let Some(plugin) = ensure_table_for_write(&mut plugins[plugin_key]) else {
|
||||
return false;
|
||||
};
|
||||
let mut replacement = value(enabled);
|
||||
if let Some(existing) = plugin.get("enabled") {
|
||||
preserve_decor(existing, &mut replacement);
|
||||
}
|
||||
plugin["enabled"] = replacement;
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool {
|
||||
let root = doc.as_table_mut();
|
||||
let Some(plugins_item) = root.get_mut("plugins") else {
|
||||
return false;
|
||||
};
|
||||
let Some(plugins) = ensure_table_for_read(plugins_item) else {
|
||||
return false;
|
||||
};
|
||||
plugins.remove(plugin_key).is_some()
|
||||
}
|
||||
|
||||
fn ensure_plugins_table(doc: &mut DocumentMut) -> Option<&mut TomlTable> {
|
||||
let root = doc.as_table_mut();
|
||||
if !root.contains_key("plugins") {
|
||||
root.insert("plugins", TomlItem::Table(new_implicit_table()));
|
||||
}
|
||||
ensure_table_for_write(root.get_mut("plugins")?)
|
||||
}
|
||||
|
||||
fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(table) => Some(table),
|
||||
TomlItem::Value(value) => {
|
||||
let table = value
|
||||
.as_inline_table()
|
||||
.map_or_else(new_implicit_table, table_from_inline);
|
||||
*item = TomlItem::Table(table);
|
||||
item.as_table_mut()
|
||||
}
|
||||
TomlItem::None => {
|
||||
*item = TomlItem::Table(new_implicit_table());
|
||||
item.as_table_mut()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> {
|
||||
match item {
|
||||
TomlItem::Table(_) => {}
|
||||
TomlItem::Value(value) => {
|
||||
let inline = value.as_inline_table()?.clone();
|
||||
*item = TomlItem::Table(table_from_inline(&inline));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
item.as_table_mut()
|
||||
}
|
||||
|
||||
fn table_from_inline(inline: &toml_edit::InlineTable) -> TomlTable {
|
||||
let mut table = new_implicit_table();
|
||||
for (key, value) in inline.iter() {
|
||||
let mut value = value.clone();
|
||||
value.decor_mut().set_suffix("");
|
||||
table.insert(key, TomlItem::Value(value));
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
fn new_implicit_table() -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
|
||||
fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {
|
||||
if let (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) =
|
||||
(existing, replacement)
|
||||
{
|
||||
replacement_value
|
||||
.decor_mut()
|
||||
.clone_from(existing_value.decor());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_user_plugin_enabled_writes_plugin_entry() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
set_user_plugin_enabled(
|
||||
codex_home.path(),
|
||||
"demo@market".to_string(),
|
||||
/*enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config = read_config(codex_home.path());
|
||||
let expected: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[plugins."demo@market"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_user_plugin_enabled_preserves_existing_plugin_fields() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[plugins."demo@market"]
|
||||
enabled = false
|
||||
source = "/tmp/plugin"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
set_user_plugin_enabled(
|
||||
codex_home.path(),
|
||||
"demo@market".to_string(),
|
||||
/*enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config = read_config(codex_home.path());
|
||||
let expected: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[plugins."demo@market"]
|
||||
enabled = true
|
||||
source = "/tmp/plugin"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_user_plugin_removes_empty_plugins_table() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[plugins."demo@market"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
clear_user_plugin(codex_home.path(), "demo@market".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap(),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_user_plugin_missing_entry_does_not_create_config() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
clear_user_plugin(codex_home.path(), "demo@market".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
async fn set_user_plugin_enabled_follows_config_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let target_path = codex_home.path().join("target_config.toml");
|
||||
symlink(&target_path, codex_home.path().join(CONFIG_TOML_FILE)).unwrap();
|
||||
|
||||
set_user_plugin_enabled(
|
||||
codex_home.path(),
|
||||
"demo@market".to_string(),
|
||||
/*enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config =
|
||||
toml::from_str::<toml::Value>(&fs::read_to_string(target_path).unwrap()).unwrap();
|
||||
let expected: toml::Value = toml::from_str(
|
||||
r#"
|
||||
[plugins."demo@market"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
fn read_config(codex_home: &Path) -> toml::Value {
|
||||
toml::from_str(&fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap()).unwrap()
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,12 @@ pub struct ConfigLayerStack {
|
||||
|
||||
/// Whether execpolicy should skip `.rules` files from user and project config-layer folders.
|
||||
ignore_user_and_project_exec_policy_rules: bool,
|
||||
|
||||
/// Startup warnings discovered while building this stack.
|
||||
///
|
||||
/// `None` means the loader did not check for stack-level warnings, while
|
||||
/// `Some(vec![])` means it checked and found nothing to report.
|
||||
startup_warnings: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ConfigLayerStack {
|
||||
@@ -185,6 +191,7 @@ impl ConfigLayerStack {
|
||||
requirements,
|
||||
requirements_toml,
|
||||
ignore_user_and_project_exec_policy_rules: false,
|
||||
startup_warnings: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,6 +207,15 @@ impl ConfigLayerStack {
|
||||
self.ignore_user_and_project_exec_policy_rules
|
||||
}
|
||||
|
||||
pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec<String>) -> Self {
|
||||
self.startup_warnings = Some(startup_warnings);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn startup_warnings(&self) -> Option<&[String]> {
|
||||
self.startup_warnings.as_deref()
|
||||
}
|
||||
|
||||
/// Returns the raw user config layer, if any.
|
||||
///
|
||||
/// This does not merge other config layers or apply any requirements.
|
||||
@@ -239,6 +255,7 @@ impl ConfigLayerStack {
|
||||
requirements_toml: self.requirements_toml.clone(),
|
||||
ignore_user_and_project_exec_policy_rules: self
|
||||
.ignore_user_and_project_exec_policy_rules,
|
||||
startup_warnings: self.startup_warnings.clone(),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -262,6 +279,7 @@ impl ConfigLayerStack {
|
||||
requirements_toml: self.requirements_toml.clone(),
|
||||
ignore_user_and_project_exec_policy_rules: self
|
||||
.ignore_user_and_project_exec_policy_rules,
|
||||
startup_warnings: self.startup_warnings.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct TuiGlobalKeymap {
|
||||
pub queue: Option<KeybindingsSpec>,
|
||||
/// Toggle the composer shortcut overlay.
|
||||
pub toggle_shortcuts: Option<KeybindingsSpec>,
|
||||
/// Toggle Vim mode for the composer input.
|
||||
pub toggle_vim_mode: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Chat context keybindings.
|
||||
@@ -173,6 +175,95 @@ pub struct TuiEditorKeymap {
|
||||
pub yank: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim normal-mode keybindings for modal editing inside text areas.
|
||||
///
|
||||
/// Actions that use uppercase letters (like `A` for append-line-end) should
|
||||
/// be specified as `shift-a` in config; the runtime matcher handles
|
||||
/// cross-terminal shift-reporting differences automatically.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiVimNormalKeymap {
|
||||
/// Enter insert mode at cursor (`i`).
|
||||
pub enter_insert: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode after cursor (`a`).
|
||||
pub append_after_cursor: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode at end of line (`A`).
|
||||
pub append_line_end: Option<KeybindingsSpec>,
|
||||
/// Enter insert mode at first non-blank of line (`I`).
|
||||
pub insert_line_start: Option<KeybindingsSpec>,
|
||||
/// Open a new line below and enter insert mode (`o`).
|
||||
pub open_line_below: Option<KeybindingsSpec>,
|
||||
/// Open a new line above and enter insert mode (`O`).
|
||||
pub open_line_above: Option<KeybindingsSpec>,
|
||||
/// Move cursor left (`h`).
|
||||
pub move_left: Option<KeybindingsSpec>,
|
||||
/// Move cursor right (`l`).
|
||||
pub move_right: Option<KeybindingsSpec>,
|
||||
/// Move cursor up (`k`), or recall older composer history at history boundaries.
|
||||
pub move_up: Option<KeybindingsSpec>,
|
||||
/// Move cursor down (`j`), or recall newer composer history at history boundaries.
|
||||
pub move_down: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of next word (`w`).
|
||||
pub move_word_forward: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of previous word (`b`).
|
||||
pub move_word_backward: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of current/next word (`e`).
|
||||
pub move_word_end: Option<KeybindingsSpec>,
|
||||
/// Move cursor to start of line (`0`).
|
||||
pub move_line_start: Option<KeybindingsSpec>,
|
||||
/// Move cursor to end of line (`$`).
|
||||
pub move_line_end: Option<KeybindingsSpec>,
|
||||
/// Delete character under cursor (`x`).
|
||||
pub delete_char: Option<KeybindingsSpec>,
|
||||
/// Delete from cursor to end of line (`D`).
|
||||
pub delete_to_line_end: Option<KeybindingsSpec>,
|
||||
/// Yank the entire line (`Y`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Paste after cursor (`p`).
|
||||
pub paste_after: Option<KeybindingsSpec>,
|
||||
/// Begin delete operator; next key selects motion (`d`).
|
||||
pub start_delete_operator: Option<KeybindingsSpec>,
|
||||
/// Begin yank operator; next key selects motion (`y`).
|
||||
pub start_yank_operator: Option<KeybindingsSpec>,
|
||||
/// Cancel a pending operator and return to normal mode.
|
||||
pub cancel_operator: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Vim operator-pending keybindings for modal editing inside text areas.
|
||||
///
|
||||
/// This context is active only while waiting for a motion after `d` or `y`.
|
||||
/// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing
|
||||
/// `Esc` cancels the pending operator and returns to normal mode without
|
||||
/// modifying text.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiVimOperatorKeymap {
|
||||
/// Repeat delete operator to delete the whole line (`dd`).
|
||||
pub delete_line: Option<KeybindingsSpec>,
|
||||
/// Repeat yank operator to yank the whole line (`yy`).
|
||||
pub yank_line: Option<KeybindingsSpec>,
|
||||
/// Motion: left (`h`).
|
||||
pub motion_left: Option<KeybindingsSpec>,
|
||||
/// Motion: right (`l`).
|
||||
pub motion_right: Option<KeybindingsSpec>,
|
||||
/// Motion: up one line (`k`).
|
||||
pub motion_up: Option<KeybindingsSpec>,
|
||||
/// Motion: down one line (`j`).
|
||||
pub motion_down: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of next word (`w`).
|
||||
pub motion_word_forward: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of previous word (`b`).
|
||||
pub motion_word_backward: Option<KeybindingsSpec>,
|
||||
/// Motion: to end of current/next word (`e`).
|
||||
pub motion_word_end: Option<KeybindingsSpec>,
|
||||
/// Motion: to start of line (`0`).
|
||||
pub motion_line_start: Option<KeybindingsSpec>,
|
||||
/// Motion: to end of line (`$`).
|
||||
pub motion_line_end: Option<KeybindingsSpec>,
|
||||
/// Cancel the pending operator and return to normal mode.
|
||||
pub cancel: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Pager context keybindings for transcript and static overlays.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -261,6 +352,10 @@ pub struct TuiKeymap {
|
||||
#[serde(default)]
|
||||
pub editor: TuiEditorKeymap,
|
||||
#[serde(default)]
|
||||
pub vim_normal: TuiVimNormalKeymap,
|
||||
#[serde(default)]
|
||||
pub vim_operator: TuiVimOperatorKeymap,
|
||||
#[serde(default)]
|
||||
pub pager: TuiPagerKeymap,
|
||||
#[serde(default)]
|
||||
pub list: TuiListKeymap,
|
||||
|
||||
@@ -38,6 +38,8 @@ pub use crate::tui_keymap::TuiGlobalKeymap;
|
||||
pub use crate::tui_keymap::TuiKeymap;
|
||||
pub use crate::tui_keymap::TuiListKeymap;
|
||||
pub use crate::tui_keymap::TuiPagerKeymap;
|
||||
pub use crate::tui_keymap::TuiVimNormalKeymap;
|
||||
pub use crate::tui_keymap::TuiVimOperatorKeymap;
|
||||
|
||||
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2;
|
||||
@@ -609,6 +611,11 @@ pub struct Tui {
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tooltips: bool,
|
||||
|
||||
/// Start the composer in Vim mode (`Normal`) by default.
|
||||
/// Defaults to `false`.
|
||||
#[serde(default)]
|
||||
pub vim_mode_default: bool,
|
||||
|
||||
/// Controls whether the TUI uses the terminal's alternate screen buffer.
|
||||
///
|
||||
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
|
||||
@@ -627,6 +634,11 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Color status line items with colors derived from the active syntax theme.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub status_line_use_colors: bool,
|
||||
|
||||
/// Ordered list of terminal title item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items into the terminal window/tab title.
|
||||
|
||||
@@ -13,6 +13,7 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#![deny(private_bounds, private_interfaces, unreachable_pub)]
|
||||
|
||||
pub use codex_analytics::AnalyticsEventsClient;
|
||||
pub use codex_app_server_protocol::ServerNotification;
|
||||
pub use codex_app_server_protocol::item_event_to_server_notification;
|
||||
pub use codex_arg0::Arg0DispatchPaths;
|
||||
pub use codex_arg0::arg0_dispatch_or_else;
|
||||
pub use codex_config::ConfigLayerStack;
|
||||
@@ -35,13 +37,13 @@ pub use codex_core::config::Permissions;
|
||||
pub use codex_core::config::TerminalResizeReflowConfig;
|
||||
pub use codex_core::config::ThreadStoreConfig;
|
||||
pub use codex_core::config::find_codex_home;
|
||||
pub use codex_core::plugins::PluginsManager;
|
||||
pub use codex_core::skills::SkillsManager;
|
||||
pub use codex_core::thread_store_from_config;
|
||||
pub use codex_exec_server::EnvironmentManager;
|
||||
pub use codex_exec_server::EnvironmentManagerArgs;
|
||||
pub use codex_exec_server::ExecServerRuntimePaths;
|
||||
pub use codex_features::Feature;
|
||||
pub use codex_features::Features;
|
||||
pub use codex_login::AuthManager;
|
||||
pub use codex_login::default_client::set_default_originator;
|
||||
pub use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
|
||||
@@ -13,6 +13,8 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
@@ -41,7 +43,6 @@ url = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod installed_marketplaces;
|
||||
pub mod loader;
|
||||
mod manager;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod marketplace_add;
|
||||
@@ -8,8 +9,11 @@ pub mod marketplace_upgrade;
|
||||
pub mod remote;
|
||||
pub mod remote_bundle;
|
||||
pub mod remote_legacy;
|
||||
pub(crate) mod startup_remote_sync;
|
||||
pub mod startup_sync;
|
||||
pub mod store;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
pub mod toggles;
|
||||
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
@@ -22,11 +26,34 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
|
||||
"gmail@openai-curated",
|
||||
"google-calendar@openai-curated",
|
||||
"google-drive@openai-curated",
|
||||
"canva@openai-curated",
|
||||
"teams@openai-curated",
|
||||
"sharepoint@openai-curated",
|
||||
"outlook-email@openai-curated",
|
||||
"outlook-calendar@openai-curated",
|
||||
"linear@openai-curated",
|
||||
"figma@openai-curated",
|
||||
"chrome@openai-bundled",
|
||||
"computer-use@openai-bundled",
|
||||
];
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<codex_config::McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<codex_config::McpServerConfig>;
|
||||
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginDetailsUnavailableReason;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginReadOutcome;
|
||||
pub use manager::PluginReadRequest;
|
||||
pub use manager::PluginRemoteSyncError;
|
||||
pub use manager::PluginUninstallError;
|
||||
pub use manager::PluginsConfigInput;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::RemotePluginSyncResult;
|
||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
||||
|
||||
@@ -927,6 +927,7 @@ pub async fn plugin_telemetry_metadata_from_root(
|
||||
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: plugin_id.clone(),
|
||||
remote_plugin_id: None,
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
use super::PluginLoadOutcome;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use crate::SkillMetadata;
|
||||
use crate::config::Config;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use super::startup_remote_sync::start_startup_remote_plugin_sync_once;
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_mcp_servers;
|
||||
use crate::loader::load_plugin_skills;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
use crate::loader::log_plugin_load_errors;
|
||||
use crate::loader::materialize_marketplace_plugin_source;
|
||||
use crate::loader::plugin_telemetry_metadata_from_root;
|
||||
use crate::loader::refresh_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use crate::loader::remote_installed_plugins_to_config;
|
||||
use crate::manifest::PluginManifestInterface;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::marketplace::MarketplaceError;
|
||||
use crate::marketplace::MarketplaceInterface;
|
||||
use crate::marketplace::MarketplaceListError;
|
||||
use crate::marketplace::MarketplacePluginAuthPolicy;
|
||||
use crate::marketplace::MarketplacePluginPolicy;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::ResolvedMarketplacePlugin;
|
||||
use crate::marketplace::find_installable_marketplace_plugin;
|
||||
use crate::marketplace::find_marketplace_plugin;
|
||||
use crate::marketplace::list_marketplaces;
|
||||
use crate::marketplace::load_marketplace;
|
||||
use crate::marketplace::plugin_interface_with_marketplace_category;
|
||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use crate::marketplace_upgrade::configured_git_marketplace_names;
|
||||
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::remote::RemotePluginCatalogError;
|
||||
use crate::remote::RemotePluginServiceConfig;
|
||||
use crate::remote_legacy::RemotePluginFetchError;
|
||||
use crate::remote_legacy::RemotePluginMutationError;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::startup_sync::read_curated_plugins_sha;
|
||||
use crate::startup_sync::sync_openai_plugins_repo;
|
||||
use crate::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::PluginStoreError;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::PluginConfigEdit;
|
||||
use codex_config::apply_user_plugin_config_edits;
|
||||
use codex_config::clear_user_plugin;
|
||||
use codex_config::set_user_plugin_enabled;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use codex_core_plugins::loader::curated_plugin_cache_version;
|
||||
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
|
||||
use codex_core_plugins::loader::load_plugin_apps;
|
||||
use codex_core_plugins::loader::load_plugin_mcp_servers;
|
||||
use codex_core_plugins::loader::load_plugin_skills;
|
||||
use codex_core_plugins::loader::load_plugins_from_layer_stack;
|
||||
use codex_core_plugins::loader::log_plugin_load_errors;
|
||||
use codex_core_plugins::loader::materialize_marketplace_plugin_source;
|
||||
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
|
||||
use codex_core_plugins::loader::refresh_curated_plugin_cache;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use codex_core_plugins::loader::remote_installed_plugins_to_config;
|
||||
use codex_core_plugins::manifest::PluginManifestInterface;
|
||||
use codex_core_plugins::manifest::load_plugin_manifest;
|
||||
use codex_core_plugins::marketplace::MarketplaceError;
|
||||
use codex_core_plugins::marketplace::MarketplaceInterface;
|
||||
use codex_core_plugins::marketplace::MarketplaceListError;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginSource;
|
||||
use codex_core_plugins::marketplace::ResolvedMarketplacePlugin;
|
||||
use codex_core_plugins::marketplace::find_installable_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::find_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::list_marketplaces;
|
||||
use codex_core_plugins::marketplace::load_marketplace;
|
||||
use codex_core_plugins::marketplace::plugin_interface_with_marketplace_category;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
|
||||
use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||
use codex_core_plugins::remote::RemoteInstalledPlugin;
|
||||
use codex_core_plugins::remote::RemotePluginCatalogError;
|
||||
use codex_core_plugins::remote::RemotePluginServiceConfig;
|
||||
use codex_core_plugins::remote_legacy::RemotePluginFetchError;
|
||||
use codex_core_plugins::remote_legacy::RemotePluginMutationError;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_core_plugins::startup_sync::read_curated_plugins_sha;
|
||||
use codex_core_plugins::startup_sync::sync_openai_plugins_repo;
|
||||
use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use codex_core_plugins::store::PluginStore;
|
||||
use codex_core_plugins::store::PluginStoreError;
|
||||
use codex_features::Feature;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::AppConnectorId;
|
||||
@@ -72,7 +72,6 @@ use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Semaphore;
|
||||
use toml_edit::value;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -80,6 +79,33 @@ static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60 * 60 * 3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginsConfigInput {
|
||||
pub config_layer_stack: ConfigLayerStack,
|
||||
pub plugins_enabled: bool,
|
||||
pub remote_plugin_enabled: bool,
|
||||
pub plugin_hooks_enabled: bool,
|
||||
pub chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
impl PluginsConfigInput {
|
||||
pub fn new(
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
plugins_enabled: bool,
|
||||
remote_plugin_enabled: bool,
|
||||
plugin_hooks_enabled: bool,
|
||||
chatgpt_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
config_layer_stack,
|
||||
plugins_enabled,
|
||||
remote_plugin_enabled,
|
||||
plugin_hooks_enabled,
|
||||
chatgpt_base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct FeaturedPluginIdsCacheKey {
|
||||
chatgpt_base_url: String,
|
||||
@@ -143,14 +169,14 @@ struct ConfiguredMarketplaceUpgradeState {
|
||||
in_flight: bool,
|
||||
}
|
||||
|
||||
fn remote_plugin_service_config(config: &Config) -> RemotePluginServiceConfig {
|
||||
fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig {
|
||||
RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn featured_plugin_ids_cache_key(
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> FeaturedPluginIdsCacheKey {
|
||||
FeaturedPluginIdsCacheKey {
|
||||
@@ -361,8 +387,6 @@ pub struct PluginsManager {
|
||||
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
|
||||
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
|
||||
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
|
||||
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
|
||||
// remote installed state cannot remain effective for a different account.
|
||||
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||
remote_sync_lock: Semaphore,
|
||||
@@ -430,21 +454,21 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome {
|
||||
pub async fn plugins_for_config(&self, config: &PluginsConfigInput) -> PluginLoadOutcome {
|
||||
self.plugins_for_config_with_force_reload(config, /*force_reload*/ false)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn plugins_for_config_with_force_reload(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
force_reload: bool,
|
||||
) -> PluginLoadOutcome {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
|
||||
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
|
||||
let plugin_hooks_enabled = config.plugin_hooks_enabled;
|
||||
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
|
||||
if !force_reload
|
||||
&& let Some(outcome) =
|
||||
@@ -495,10 +519,10 @@ impl PluginsManager {
|
||||
pub async fn plugins_for_layer_stack(
|
||||
&self,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
plugin_hooks_feature_enabled: bool,
|
||||
) -> PluginLoadOutcome {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
load_plugins_from_layer_stack(
|
||||
@@ -515,15 +539,11 @@ impl PluginsManager {
|
||||
pub async fn effective_skill_roots_for_layer_stack(
|
||||
&self,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
self.plugins_for_layer_stack(
|
||||
config_layer_stack,
|
||||
config,
|
||||
config.features.enabled(Feature::PluginHooks),
|
||||
)
|
||||
.await
|
||||
.effective_skill_roots()
|
||||
self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled)
|
||||
.await
|
||||
.effective_skill_roots()
|
||||
}
|
||||
|
||||
fn cached_enabled_outcome(
|
||||
@@ -550,8 +570,11 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_configs(&self, config: &Config) -> HashMap<String, PluginConfig> {
|
||||
if !config.features.enabled(Feature::RemotePlugin) {
|
||||
fn remote_installed_plugin_configs(
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
if !config.remote_plugin_enabled {
|
||||
return HashMap::new();
|
||||
}
|
||||
|
||||
@@ -596,7 +619,7 @@ impl PluginsManager {
|
||||
|
||||
pub fn maybe_start_remote_installed_plugins_cache_refresh(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
@@ -610,7 +633,7 @@ impl PluginsManager {
|
||||
|
||||
pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
@@ -624,14 +647,12 @@ impl PluginsManager {
|
||||
|
||||
fn maybe_start_remote_installed_plugins_cache_refresh_with_notify(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
notify: RemoteInstalledPluginsCacheRefreshNotify,
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
if !config.features.enabled(Feature::Plugins)
|
||||
|| !config.features.enabled(Feature::RemotePlugin)
|
||||
{
|
||||
if !config.plugins_enabled || !config.remote_plugin_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -645,15 +666,49 @@ impl PluginsManager {
|
||||
);
|
||||
}
|
||||
|
||||
fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
self: &Arc<Self>,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
if !config.plugins_enabled || !config.remote_plugin_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let manager = Arc::clone(self);
|
||||
let config_for_refresh = config.clone();
|
||||
let auth_for_refresh = auth.clone();
|
||||
let on_local_cache_changed = Arc::new(move || {
|
||||
manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config_for_refresh,
|
||||
auth_for_refresh.clone(),
|
||||
on_effective_plugins_changed.clone(),
|
||||
);
|
||||
});
|
||||
|
||||
crate::remote::maybe_start_remote_installed_plugin_bundle_sync(
|
||||
self.codex_home.clone(),
|
||||
remote_plugin_service_config(config),
|
||||
auth,
|
||||
Some(on_local_cache_changed),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn maybe_start_plugin_list_background_tasks_for_config(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
roots: &[AbsolutePathBuf],
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
self.maybe_start_non_curated_plugin_cache_refresh(roots);
|
||||
self.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
config,
|
||||
auth.clone(),
|
||||
on_effective_plugins_changed.clone(),
|
||||
);
|
||||
self.maybe_start_remote_installed_plugin_bundle_sync(
|
||||
config,
|
||||
auth,
|
||||
on_effective_plugins_changed,
|
||||
@@ -710,10 +765,10 @@ impl PluginsManager {
|
||||
|
||||
pub async fn featured_plugin_ids_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<String>, RemotePluginFetchError> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
@@ -721,13 +776,12 @@ impl PluginsManager {
|
||||
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
|
||||
return Ok(featured_plugin_ids);
|
||||
}
|
||||
let featured_plugin_ids =
|
||||
codex_core_plugins::remote_legacy::fetch_remote_featured_plugin_ids(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
self.restriction_product,
|
||||
)
|
||||
.await?;
|
||||
let featured_plugin_ids = crate::remote_legacy::fetch_remote_featured_plugin_ids(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
self.restriction_product,
|
||||
)
|
||||
.await?;
|
||||
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
|
||||
Ok(featured_plugin_ids)
|
||||
}
|
||||
@@ -746,7 +800,7 @@ impl PluginsManager {
|
||||
|
||||
pub async fn install_plugin_with_remote_sync(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
@@ -757,7 +811,7 @@ impl PluginsManager {
|
||||
)?;
|
||||
let plugin_id = resolved.plugin_id.as_key();
|
||||
// This only forwards the backend mutation before the local install flow.
|
||||
codex_core_plugins::remote_legacy::enable_remote_plugin(
|
||||
crate::remote_legacy::enable_remote_plugin(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
&plugin_id,
|
||||
@@ -800,18 +854,13 @@ impl PluginsManager {
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
|
||||
ConfigEditsBuilder::new(&self.codex_home)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"plugins".to_string(),
|
||||
result.plugin_id.as_key(),
|
||||
"enabled".to_string(),
|
||||
],
|
||||
value: value(true),
|
||||
}])
|
||||
.apply()
|
||||
.await
|
||||
.map_err(PluginInstallError::from)?;
|
||||
set_user_plugin_enabled(
|
||||
&self.codex_home,
|
||||
result.plugin_id.as_key(),
|
||||
/*enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
|
||||
let analytics_events_client = match self.analytics_events_client.read() {
|
||||
Ok(client) => client.clone(),
|
||||
@@ -839,7 +888,7 @@ impl PluginsManager {
|
||||
|
||||
pub async fn uninstall_plugin_with_remote_sync(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: String,
|
||||
) -> Result<(), PluginUninstallError> {
|
||||
@@ -848,7 +897,7 @@ impl PluginsManager {
|
||||
let plugin_id = PluginId::parse(&plugin_id)?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
// This only forwards the backend mutation before the local uninstall flow.
|
||||
codex_core_plugins::remote_legacy::uninstall_remote_plugin(
|
||||
crate::remote_legacy::uninstall_remote_plugin(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
&plugin_key,
|
||||
@@ -870,12 +919,9 @@ impl PluginsManager {
|
||||
.await
|
||||
.map_err(PluginUninstallError::join)??;
|
||||
|
||||
ConfigEditsBuilder::new(&self.codex_home)
|
||||
.with_edits([ConfigEdit::ClearPath {
|
||||
segments: vec!["plugins".to_string(), plugin_id.as_key()],
|
||||
}])
|
||||
.apply()
|
||||
.await?;
|
||||
clear_user_plugin(&self.codex_home, plugin_id.as_key())
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
|
||||
let analytics_events_client = match self.analytics_events_client.read() {
|
||||
Ok(client) => client.clone(),
|
||||
@@ -892,7 +938,7 @@ impl PluginsManager {
|
||||
|
||||
pub async fn sync_plugins_from_remote(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
additive_only: bool,
|
||||
) -> Result<RemotePluginSyncResult, PluginRemoteSyncError> {
|
||||
@@ -900,12 +946,12 @@ impl PluginsManager {
|
||||
PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed"))
|
||||
})?;
|
||||
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return Ok(RemotePluginSyncResult::default());
|
||||
}
|
||||
|
||||
info!("starting remote plugin sync");
|
||||
let remote_plugins = codex_core_plugins::remote_legacy::fetch_remote_plugin_status(
|
||||
let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
)
|
||||
@@ -1051,9 +1097,9 @@ impl PluginsManager {
|
||||
|
||||
if current_enabled != Some(true) {
|
||||
result.enabled_plugin_ids.push(plugin_key.clone());
|
||||
config_edits.push(ConfigEdit::SetPath {
|
||||
segments: vec!["plugins".to_string(), plugin_key, "enabled".to_string()],
|
||||
value: value(true),
|
||||
config_edits.push(PluginConfigEdit::SetEnabled {
|
||||
plugin_key,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
} else if !additive_only {
|
||||
@@ -1064,9 +1110,7 @@ impl PluginsManager {
|
||||
result.uninstalled_plugin_ids.push(plugin_key.clone());
|
||||
}
|
||||
if current_enabled.is_some() {
|
||||
config_edits.push(ConfigEdit::ClearPath {
|
||||
segments: vec!["plugins".to_string(), plugin_key],
|
||||
});
|
||||
config_edits.push(PluginConfigEdit::Clear { plugin_key });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1091,13 +1135,10 @@ impl PluginsManager {
|
||||
let config_result = if config_edits.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
ConfigEditsBuilder::new(&self.codex_home)
|
||||
.with_edits(config_edits)
|
||||
.apply()
|
||||
.await
|
||||
apply_user_plugin_config_edits(&self.codex_home, config_edits).await
|
||||
};
|
||||
self.clear_cache();
|
||||
config_result?;
|
||||
config_result.map_err(anyhow::Error::from)?;
|
||||
|
||||
info!(
|
||||
marketplace = %marketplace_name,
|
||||
@@ -1115,10 +1156,10 @@ impl PluginsManager {
|
||||
|
||||
pub fn list_marketplaces_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return Ok(ConfiguredMarketplaceListOutcome::default());
|
||||
}
|
||||
|
||||
@@ -1175,10 +1216,10 @@ impl PluginsManager {
|
||||
|
||||
pub async fn read_plugin_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
request: &PluginReadRequest,
|
||||
) -> Result<PluginReadOutcome, MarketplaceError> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
if !config.plugins_enabled {
|
||||
return Err(MarketplaceError::PluginsDisabled);
|
||||
}
|
||||
|
||||
@@ -1216,9 +1257,9 @@ impl PluginsManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn read_plugin_detail_for_marketplace_plugin(
|
||||
pub async fn read_plugin_detail_for_marketplace_plugin(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
marketplace_name: &str,
|
||||
plugin: ConfiguredMarketplacePlugin,
|
||||
) -> Result<PluginDetail, MarketplaceError> {
|
||||
@@ -1332,11 +1373,11 @@ impl PluginsManager {
|
||||
|
||||
pub fn maybe_start_plugin_startup_tasks_for_config(
|
||||
self: &Arc<Self>,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
if config.features.enabled(Feature::Plugins) {
|
||||
if config.plugins_enabled {
|
||||
self.start_curated_repo_sync();
|
||||
let should_spawn_marketplace_auto_upgrade = {
|
||||
let mut state = match self.configured_marketplace_upgrade_state.write() {
|
||||
@@ -1396,7 +1437,7 @@ impl PluginsManager {
|
||||
auth_manager.clone(),
|
||||
);
|
||||
|
||||
if config.features.enabled(Feature::RemotePlugin) {
|
||||
if config.remote_plugin_enabled {
|
||||
let config = config.clone();
|
||||
let manager = Arc::clone(self);
|
||||
let auth_manager = auth_manager.clone();
|
||||
@@ -1404,6 +1445,11 @@ impl PluginsManager {
|
||||
tokio::spawn(async move {
|
||||
let auth = auth_manager.auth().await;
|
||||
manager.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
&config,
|
||||
auth.clone(),
|
||||
on_effective_plugins_changed.clone(),
|
||||
);
|
||||
manager.maybe_start_remote_installed_plugin_bundle_sync(
|
||||
&config,
|
||||
auth,
|
||||
on_effective_plugins_changed,
|
||||
@@ -1430,7 +1476,7 @@ impl PluginsManager {
|
||||
|
||||
pub fn upgrade_configured_marketplaces_for_config(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
marketplace_name: Option<&str>,
|
||||
) -> Result<ConfiguredMarketplaceUpgradeOutcome, String> {
|
||||
if let Some(marketplace_name) = marketplace_name
|
||||
@@ -1648,7 +1694,7 @@ impl PluginsManager {
|
||||
}
|
||||
};
|
||||
|
||||
let installed_plugins = codex_core_plugins::remote::fetch_remote_installed_plugins(
|
||||
let installed_plugins = crate::remote::fetch_remote_installed_plugins(
|
||||
&request.service_config,
|
||||
request.auth.as_ref(),
|
||||
)
|
||||
@@ -1751,7 +1797,10 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_plugin_states(&self, config: &Config) -> (HashSet<String>, HashSet<String>) {
|
||||
fn configured_plugin_states(
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
) -> (HashSet<String>, HashSet<String>) {
|
||||
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
|
||||
let installed_plugins = configured_plugins
|
||||
.keys()
|
||||
@@ -1771,7 +1820,7 @@ impl PluginsManager {
|
||||
|
||||
fn marketplace_roots(
|
||||
&self,
|
||||
config: &Config,
|
||||
config: &PluginsConfigInput,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
|
||||
+31
-39
@@ -1,15 +1,22 @@
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use crate::LoadedPlugin;
|
||||
use crate::PluginLoadOutcome;
|
||||
use crate::installed_marketplaces::marketplace_install_root;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
use crate::loader::refresh_non_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use crate::marketplace::MarketplacePluginInstallPolicy;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::test_support::load_plugins_config as load_plugins_config_input;
|
||||
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::test_support::write_file;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::AppToolApproval;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
@@ -17,12 +24,6 @@ use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
|
||||
use codex_core_plugins::loader::load_plugins_from_layer_stack;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
@@ -140,13 +141,8 @@ async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> Plugi
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config {
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load")
|
||||
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
||||
load_plugins_config_input(codex_home, cwd).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -350,14 +346,12 @@ remote_plugin = true
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![
|
||||
codex_core_plugins::remote::RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
}]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(
|
||||
@@ -381,14 +375,12 @@ remote_plugin = true
|
||||
|
||||
let config = load_config(codex_home.path(), codex_home.path()).await;
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![
|
||||
codex_core_plugins::remote::RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
}]);
|
||||
|
||||
let outcome = manager.plugins_for_config(&config).await;
|
||||
assert_eq!(outcome, PluginLoadOutcome::default());
|
||||
@@ -423,7 +415,7 @@ enabled = false
|
||||
enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
|
||||
let skill_path = dunce::canonicalize(skill_path)
|
||||
let skill_path = std::fs::canonicalize(skill_path)
|
||||
.expect("skill path should canonicalize")
|
||||
.abs();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_app_server_protocol::SkillInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -15,9 +18,17 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
mod remote_installed_plugin_sync;
|
||||
mod share;
|
||||
|
||||
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
|
||||
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;
|
||||
pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
|
||||
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
|
||||
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
|
||||
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
|
||||
pub use share::RemotePluginShareSaveResult;
|
||||
pub use share::delete_remote_plugin_share;
|
||||
pub use share::list_remote_plugin_shares;
|
||||
@@ -31,6 +42,7 @@ pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace P
|
||||
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginServiceConfig {
|
||||
@@ -60,9 +72,17 @@ pub struct RemotePluginSummary {
|
||||
pub enabled: bool,
|
||||
pub install_policy: PluginInstallPolicy,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub availability: PluginAvailability,
|
||||
pub interface: Option<PluginInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginShareSummary {
|
||||
pub summary: RemotePluginSummary,
|
||||
pub share_url: Option<String>,
|
||||
pub local_plugin_path: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginDetail {
|
||||
pub marketplace_name: String,
|
||||
@@ -84,6 +104,32 @@ pub struct RemotePluginSkill {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemotePluginSkillDetail {
|
||||
pub contents: Option<String>,
|
||||
}
|
||||
|
||||
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
|
||||
!plugin_id.is_empty()
|
||||
&& plugin_id
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
||||
}
|
||||
|
||||
pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> {
|
||||
if !is_valid_remote_plugin_id(plugin_id) {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message:
|
||||
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed"
|
||||
.to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginCatalogError {
|
||||
#[error("chatgpt authentication required for remote plugin catalog")]
|
||||
@@ -118,11 +164,25 @@ pub enum RemotePluginCatalogError {
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("invalid remote plugin catalog base URL: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("invalid remote plugin catalog base URL path")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("remote marketplace `{marketplace_name}` is not supported")]
|
||||
UnknownMarketplace { marketplace_name: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedSkillName { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
@@ -193,6 +253,14 @@ impl RemotePluginScope {
|
||||
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_marketplace_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -217,6 +285,13 @@ struct RemotePluginSkillResponse {
|
||||
interface: Option<RemotePluginSkillInterfaceResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginSkillDetailResponse {
|
||||
plugin_id: String,
|
||||
name: String,
|
||||
skill_md_contents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginReleaseInterfaceResponse {
|
||||
short_description: Option<String>,
|
||||
@@ -256,8 +331,12 @@ struct RemotePluginDirectoryItem {
|
||||
id: String,
|
||||
name: String,
|
||||
scope: RemotePluginScope,
|
||||
#[serde(default)]
|
||||
share_url: Option<String>,
|
||||
installation_policy: PluginInstallPolicy,
|
||||
authentication_policy: PluginAuthPolicy,
|
||||
#[serde(rename = "status", default)]
|
||||
availability: PluginAvailability,
|
||||
release: RemotePluginReleaseResponse,
|
||||
}
|
||||
|
||||
@@ -451,6 +530,42 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_plugin_skill_detail(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
marketplace_name: &str,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<RemotePluginSkillDetail, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
|
||||
return Err(RemotePluginCatalogError::UnknownMarketplace {
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.get(&url), auth)?;
|
||||
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
|
||||
if response.plugin_id != plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: response.plugin_id,
|
||||
});
|
||||
}
|
||||
if response.name != skill_name {
|
||||
return Err(RemotePluginCatalogError::UnexpectedSkillName {
|
||||
expected: skill_name.to_string(),
|
||||
actual: response.name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemotePluginSkillDetail {
|
||||
contents: response.skill_md_contents,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_remote_plugin_detail_with_download_url_option(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -654,6 +769,7 @@ fn build_remote_plugin_summary(
|
||||
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
|
||||
install_policy: plugin.installation_policy,
|
||||
auth_policy: plugin.authentication_policy,
|
||||
availability: plugin.availability,
|
||||
interface: remote_plugin_interface_to_info(plugin),
|
||||
}
|
||||
}
|
||||
@@ -783,12 +899,30 @@ async fn fetch_installed_plugins_for_scope(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
||||
fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_installed_plugins_for_scope_with_download_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
include_download_urls: bool,
|
||||
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
||||
let mut plugins = Vec::new();
|
||||
let mut page_token = None;
|
||||
loop {
|
||||
let response =
|
||||
get_remote_plugin_installed_page(config, auth, scope, page_token.as_deref()).await?;
|
||||
let response = get_remote_plugin_installed_page(
|
||||
config,
|
||||
auth,
|
||||
scope,
|
||||
page_token.as_deref(),
|
||||
include_download_urls,
|
||||
)
|
||||
.await?;
|
||||
plugins.extend(response.plugins);
|
||||
let Some(next_page_token) = response.pagination.next_page_token else {
|
||||
break;
|
||||
@@ -821,12 +955,16 @@ async fn get_remote_plugin_installed_page(
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
page_token: Option<&str>,
|
||||
include_download_urls: bool,
|
||||
) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/ps/plugins/installed");
|
||||
let client = build_reqwest_client();
|
||||
let mut request = authenticated_request(client.get(&url), auth)?;
|
||||
request = request.query(&[("scope", scope.api_value())]);
|
||||
if include_download_urls {
|
||||
request = request.query(&[("includeDownloadUrls", true)]);
|
||||
}
|
||||
if let Some(page_token) = page_token {
|
||||
request = request.query(&[("pageToken", page_token)]);
|
||||
}
|
||||
@@ -849,6 +987,27 @@ async fn fetch_plugin_detail(
|
||||
send_and_decode(request, &url).await
|
||||
}
|
||||
|
||||
fn remote_plugin_skill_detail_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
plugin_id: &str,
|
||||
skill_name: &str,
|
||||
) -> Result<String, RemotePluginCatalogError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("ps");
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push("skills");
|
||||
segments.push(skill_name);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginCatalogError::AuthRequired);
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
use super::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use super::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
||||
use super::RemotePluginCatalogError;
|
||||
use super::RemotePluginScope;
|
||||
use super::RemotePluginServiceConfig;
|
||||
use super::ensure_chatgpt_auth;
|
||||
use super::fetch_installed_plugins_for_scope_with_download_url;
|
||||
use crate::store::PLUGINS_CACHE_DIR;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::PluginStoreError;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::PluginId;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
static REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT: OnceLock<
|
||||
Mutex<HashSet<RemoteInstalledPluginBundleSyncKey>>,
|
||||
> = OnceLock::new();
|
||||
static REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT: OnceLock<
|
||||
Mutex<HashMap<RemotePluginCacheMutationKey, usize>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RemoteInstalledPluginBundleSyncOutcome {
|
||||
pub installed_plugin_ids: Vec<String>,
|
||||
pub removed_cache_plugin_ids: Vec<String>,
|
||||
pub failed_remote_plugin_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl RemoteInstalledPluginBundleSyncOutcome {
|
||||
pub fn changed_local_cache(&self) -> bool {
|
||||
!self.installed_plugin_ids.is_empty() || !self.removed_cache_plugin_ids.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemoteInstalledPluginBundleSyncError {
|
||||
#[error("{0}")]
|
||||
Catalog(#[from] RemotePluginCatalogError),
|
||||
|
||||
#[error("{0}")]
|
||||
Store(#[from] PluginStoreError),
|
||||
|
||||
#[error("failed to join stale remote plugin cache cleanup task: {0}")]
|
||||
Join(#[from] tokio::task::JoinError),
|
||||
|
||||
#[error("failed to remove stale remote plugin cache entries: {0}")]
|
||||
CacheRemove(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct RemoteInstalledPluginBundleSyncKey {
|
||||
plugin_cache_root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct RemotePluginCacheMutationKey {
|
||||
plugin_cache_root: PathBuf,
|
||||
marketplace_name: String,
|
||||
plugin_name: String,
|
||||
}
|
||||
|
||||
pub struct RemotePluginCacheMutationGuard {
|
||||
key: RemotePluginCacheMutationKey,
|
||||
}
|
||||
|
||||
pub fn maybe_start_remote_installed_plugin_bundle_sync(
|
||||
codex_home: PathBuf,
|
||||
config: RemotePluginServiceConfig,
|
||||
auth: Option<CodexAuth>,
|
||||
on_local_cache_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
) {
|
||||
let Some(auth) = auth else {
|
||||
return;
|
||||
};
|
||||
let key = RemoteInstalledPluginBundleSyncKey {
|
||||
plugin_cache_root: remote_plugin_cache_root(&codex_home),
|
||||
};
|
||||
if !mark_remote_installed_plugin_bundle_sync_in_flight(key.clone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result =
|
||||
sync_remote_installed_plugin_bundles_once(codex_home, &config, Some(&auth)).await;
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
if outcome.changed_local_cache()
|
||||
&& let Some(on_local_cache_changed) = on_local_cache_changed
|
||||
{
|
||||
on_local_cache_changed();
|
||||
}
|
||||
info!(
|
||||
installed_plugin_ids = ?outcome.installed_plugin_ids,
|
||||
removed_cache_plugin_ids = ?outcome.removed_cache_plugin_ids,
|
||||
failed_remote_plugin_ids = ?outcome.failed_remote_plugin_ids,
|
||||
"completed remote installed plugin bundle sync"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"remote installed plugin bundle sync failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
clear_remote_installed_plugin_bundle_sync_in_flight(&key);
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
codex_home: PathBuf,
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<RemoteInstalledPluginBundleSyncOutcome, RemoteInstalledPluginBundleSyncError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let global = async {
|
||||
let scope = RemotePluginScope::Global;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
let workspace = async {
|
||||
let scope = RemotePluginScope::Workspace;
|
||||
let installed_plugins = fetch_installed_plugins_for_scope_with_download_url(
|
||||
config, auth, scope, /*include_download_urls*/ true,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
||||
};
|
||||
|
||||
let (global, workspace) = tokio::try_join!(global, workspace)?;
|
||||
let store = PluginStore::try_new(codex_home.clone())?;
|
||||
let mut installed_plugin_names_by_marketplace =
|
||||
BTreeMap::<String, BTreeSet<String>>::from_iter([
|
||||
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
|
||||
(
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
|
||||
BTreeSet::new(),
|
||||
),
|
||||
]);
|
||||
let mut installed_plugin_ids = BTreeSet::new();
|
||||
let mut failed_remote_plugin_ids = BTreeSet::new();
|
||||
|
||||
for (scope, installed_plugins) in [global, workspace] {
|
||||
let marketplace_name = scope.marketplace_name().to_string();
|
||||
for installed_plugin in installed_plugins {
|
||||
let plugin = installed_plugin.plugin;
|
||||
installed_plugin_names_by_marketplace
|
||||
.entry(marketplace_name.clone())
|
||||
.or_default()
|
||||
.insert(plugin.name.clone());
|
||||
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin with invalid local cache id"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let release_version = plugin
|
||||
.release
|
||||
.version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|version| !version.is_empty());
|
||||
if store.active_plugin_version(&plugin_id).as_deref() == release_version {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bundle = match crate::remote_bundle::validate_remote_plugin_bundle(
|
||||
&plugin.id,
|
||||
&marketplace_name,
|
||||
&plugin.name,
|
||||
release_version,
|
||||
plugin.release.bundle_download_url.as_deref(),
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"skipping remote installed plugin bundle download"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match crate::remote_bundle::download_and_install_remote_plugin_bundle(
|
||||
codex_home.clone(),
|
||||
bundle,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
installed_plugin_ids.insert(result.plugin_id.as_key());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
remote_plugin_id = %plugin.id,
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
error = %err,
|
||||
"failed to download remote installed plugin bundle"
|
||||
);
|
||||
failed_remote_plugin_ids.insert(plugin.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let removed_cache_plugin_ids = tokio::task::spawn_blocking(move || {
|
||||
remove_stale_remote_plugin_caches(
|
||||
codex_home.as_path(),
|
||||
&installed_plugin_names_by_marketplace,
|
||||
)
|
||||
})
|
||||
.await?
|
||||
.map_err(RemoteInstalledPluginBundleSyncError::CacheRemove)?;
|
||||
|
||||
Ok(RemoteInstalledPluginBundleSyncOutcome {
|
||||
installed_plugin_ids: installed_plugin_ids.into_iter().collect(),
|
||||
removed_cache_plugin_ids,
|
||||
failed_remote_plugin_ids: failed_remote_plugin_ids.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_remote_plugin_cache_mutation_in_flight(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
) -> RemotePluginCacheMutationGuard {
|
||||
let key = RemotePluginCacheMutationKey {
|
||||
plugin_cache_root: remote_plugin_cache_root(codex_home),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
plugin_name: plugin_name.to_string(),
|
||||
};
|
||||
let mutations =
|
||||
REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut mutations = match mutations.lock() {
|
||||
Ok(mutations) => mutations,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
*mutations.entry(key.clone()).or_default() += 1;
|
||||
RemotePluginCacheMutationGuard { key }
|
||||
}
|
||||
|
||||
impl Drop for RemotePluginCacheMutationGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(mutations) = REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get() else {
|
||||
return;
|
||||
};
|
||||
let mut mutations = match mutations.lock() {
|
||||
Ok(mutations) => mutations,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
if let Some(count) = mutations.get_mut(&self.key) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
mutations.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_stale_remote_plugin_caches(
|
||||
codex_home: &Path,
|
||||
installed_plugin_names_by_marketplace: &BTreeMap<String, BTreeSet<String>>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut removed_cache_plugin_ids = Vec::new();
|
||||
for marketplace_name in [
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME,
|
||||
] {
|
||||
let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name);
|
||||
if !marketplace_root.exists() {
|
||||
continue;
|
||||
}
|
||||
let installed_plugin_names = installed_plugin_names_by_marketplace
|
||||
.get(marketplace_name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for entry in fs::read_dir(&marketplace_root).map_err(|err| {
|
||||
format!(
|
||||
"failed to read remote plugin cache directory {}: {err}",
|
||||
marketplace_root.display()
|
||||
)
|
||||
})? {
|
||||
let entry = entry.map_err(|err| {
|
||||
format!(
|
||||
"failed to enumerate remote plugin cache directory {}: {err}",
|
||||
marketplace_root.display()
|
||||
)
|
||||
})?;
|
||||
let plugin_name = entry.file_name().into_string().map_err(|file_name| {
|
||||
format!(
|
||||
"remote plugin cache entry under {} is not valid UTF-8: {:?}",
|
||||
marketplace_root.display(),
|
||||
file_name
|
||||
)
|
||||
})?;
|
||||
if installed_plugin_names.contains(&plugin_name) {
|
||||
continue;
|
||||
}
|
||||
if is_remote_plugin_cache_mutation_in_flight(codex_home, marketplace_name, &plugin_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let cache_path = entry.path();
|
||||
if cache_path.is_dir() {
|
||||
fs::remove_dir_all(&cache_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to remove stale remote plugin cache entry {}: {err}",
|
||||
cache_path.display()
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
fs::remove_file(&cache_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to remove stale remote plugin cache entry {}: {err}",
|
||||
cache_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let plugin_key = PluginId::new(plugin_name.clone(), marketplace_name.to_string())
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
.unwrap_or_else(|_| format!("{plugin_name}@{marketplace_name}"));
|
||||
removed_cache_plugin_ids.push(plugin_key);
|
||||
}
|
||||
}
|
||||
|
||||
removed_cache_plugin_ids.sort();
|
||||
Ok(removed_cache_plugin_ids)
|
||||
}
|
||||
|
||||
fn remote_plugin_cache_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(PLUGINS_CACHE_DIR)
|
||||
}
|
||||
|
||||
fn is_remote_plugin_cache_mutation_in_flight(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
) -> bool {
|
||||
let Some(mutations) = REMOTE_PLUGIN_CACHE_MUTATIONS_IN_FLIGHT.get() else {
|
||||
return false;
|
||||
};
|
||||
let mutations = match mutations.lock() {
|
||||
Ok(mutations) => mutations,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
mutations.contains_key(&RemotePluginCacheMutationKey {
|
||||
plugin_cache_root: remote_plugin_cache_root(codex_home),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
plugin_name: plugin_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_remote_installed_plugin_bundle_sync_in_flight(
|
||||
key: RemoteInstalledPluginBundleSyncKey,
|
||||
) -> bool {
|
||||
let syncs =
|
||||
REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
let mut syncs = match syncs.lock() {
|
||||
Ok(syncs) => syncs,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
syncs.insert(key)
|
||||
}
|
||||
|
||||
fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPluginBundleSyncKey) {
|
||||
let Some(syncs) = REMOTE_INSTALLED_PLUGIN_BUNDLE_SYNC_IN_FLIGHT.get() else {
|
||||
return;
|
||||
};
|
||||
let mut syncs = match syncs.lock() {
|
||||
Ok(syncs) => syncs,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
syncs.remove(key);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let key = RemoteInstalledPluginBundleSyncKey {
|
||||
plugin_cache_root: remote_plugin_cache_root(codex_home.path()),
|
||||
};
|
||||
|
||||
assert!(mark_remote_installed_plugin_bundle_sync_in_flight(
|
||||
key.clone()
|
||||
));
|
||||
assert!(!mark_remote_installed_plugin_bundle_sync_in_flight(
|
||||
key.clone()
|
||||
));
|
||||
|
||||
clear_remote_installed_plugin_bundle_sync_in_flight(&key);
|
||||
assert!(mark_remote_installed_plugin_bundle_sync_in_flight(
|
||||
key.clone()
|
||||
));
|
||||
clear_remote_installed_plugin_bundle_sync_in_flight(&key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let cached_manifest = codex_home
|
||||
.path()
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
.join("linear")
|
||||
.join("1.2.3")
|
||||
.join(".codex-plugin")
|
||||
.join("plugin.json");
|
||||
std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent"))
|
||||
.expect("create cached plugin manifest parent");
|
||||
std::fs::write(&cached_manifest, r#"{"name":"linear"}"#)
|
||||
.expect("write cached plugin manifest");
|
||||
let installed_plugin_names_by_marketplace =
|
||||
BTreeMap::<String, BTreeSet<String>>::from_iter([
|
||||
(REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()),
|
||||
(
|
||||
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
|
||||
BTreeSet::new(),
|
||||
),
|
||||
]);
|
||||
|
||||
let guard = mark_remote_plugin_cache_mutation_in_flight(
|
||||
codex_home.path(),
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
"linear",
|
||||
);
|
||||
let second_guard = mark_remote_plugin_cache_mutation_in_flight(
|
||||
codex_home.path(),
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
"linear",
|
||||
);
|
||||
let removed = remove_stale_remote_plugin_caches(
|
||||
codex_home.path(),
|
||||
&installed_plugin_names_by_marketplace,
|
||||
)
|
||||
.expect("cleanup while install is guarded");
|
||||
assert_eq!(removed, Vec::<String>::new());
|
||||
assert!(cached_manifest.is_file());
|
||||
|
||||
drop(guard);
|
||||
let removed = remove_stale_remote_plugin_caches(
|
||||
codex_home.path(),
|
||||
&installed_plugin_names_by_marketplace,
|
||||
)
|
||||
.expect("cleanup while second install guard is still active");
|
||||
assert_eq!(removed, Vec::<String>::new());
|
||||
assert!(cached_manifest.is_file());
|
||||
|
||||
drop(second_guard);
|
||||
let removed = remove_stale_remote_plugin_caches(
|
||||
codex_home.path(),
|
||||
&installed_plugin_names_by_marketplace,
|
||||
)
|
||||
.expect("cleanup after install guard is dropped");
|
||||
assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]);
|
||||
assert!(!cached_manifest.exists());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use reqwest::RequestBuilder;
|
||||
@@ -13,6 +14,9 @@ use std::fs;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
mod local_paths;
|
||||
|
||||
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
@@ -53,14 +57,15 @@ struct RemoteWorkspacePluginCreateResponse {
|
||||
pub async fn save_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_path: &Path,
|
||||
codex_home: &Path,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
remote_plugin_id: Option<&str>,
|
||||
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let plugin_path = plugin_path.to_path_buf();
|
||||
let plugin_path_for_archive = plugin_path.as_path().to_path_buf();
|
||||
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
|
||||
let filename = archive_filename(&plugin_path)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path)?;
|
||||
let filename = archive_filename(&plugin_path_for_archive)?;
|
||||
let archive_bytes = archive_plugin_for_upload(&plugin_path_for_archive)?;
|
||||
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
|
||||
})
|
||||
.await
|
||||
@@ -93,6 +98,17 @@ pub async fn save_remote_plugin_share(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = local_paths::record_plugin_share_local_path(
|
||||
codex_home,
|
||||
&response.plugin_id,
|
||||
plugin_path.clone(),
|
||||
) {
|
||||
warn!(
|
||||
remote_plugin_id = %response.plugin_id,
|
||||
"failed to record plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(RemotePluginShareSaveResult {
|
||||
remote_plugin_id: response.plugin_id,
|
||||
share_url: response.share_url,
|
||||
@@ -102,7 +118,8 @@ pub async fn save_remote_plugin_share(
|
||||
pub async fn list_remote_plugin_shares(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<RemotePluginShareSummary>, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
|
||||
if created_plugins.is_empty() {
|
||||
@@ -115,16 +132,30 @@ pub async fn list_remote_plugin_shares(
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.plugin.id.clone(), plugin))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let local_plugin_paths =
|
||||
local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| {
|
||||
warn!("failed to load plugin share local path mapping: {err}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
|
||||
Ok(created_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
|
||||
.map(|plugin| {
|
||||
let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id));
|
||||
let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned();
|
||||
RemotePluginShareSummary {
|
||||
summary,
|
||||
share_url: plugin.share_url,
|
||||
local_plugin_path,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn delete_remote_plugin_share(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
@@ -132,7 +163,14 @@ pub async fn delete_remote_plugin_share(
|
||||
let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.delete(&url), auth)?;
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await
|
||||
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?;
|
||||
if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) {
|
||||
warn!(
|
||||
remote_plugin_id = %remote_plugin_id,
|
||||
"failed to remove plugin share local path mapping: {err}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_created_workspace_plugins(
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const PLUGIN_SHARE_LOCAL_PATHS_FILE: &str = ".tmp/plugin-share-local-paths-v1.json";
|
||||
static PLUGIN_SHARE_LOCAL_PATHS_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginShareLocalPaths {
|
||||
#[serde(default)]
|
||||
local_plugin_paths_by_remote_plugin_id: BTreeMap<String, AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
read_plugin_share_local_paths(codex_home)
|
||||
}
|
||||
|
||||
pub(crate) fn record_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: AbsolutePathBuf,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.insert(remote_plugin_id.to_string(), plugin_path);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_plugin_share_local_path(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
) -> io::Result<()> {
|
||||
let _guard = lock_plugin_share_local_paths()?;
|
||||
let mut mapping = read_plugin_share_local_paths_for_update(codex_home)?;
|
||||
mapping.remove(remote_plugin_id);
|
||||
write_plugin_share_local_paths(codex_home, mapping)
|
||||
}
|
||||
|
||||
fn lock_plugin_share_local_paths() -> io::Result<std::sync::MutexGuard<'static, ()>> {
|
||||
PLUGIN_SHARE_LOCAL_PATHS_LOCK
|
||||
.lock()
|
||||
.map_err(|err| io::Error::other(format!("plugin share local path lock poisoned: {err}")))
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let mapping = serde_json::from_str::<PluginShareLocalPaths>(&contents).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"failed to parse plugin share local path mapping {}: {err}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(mapping.local_plugin_paths_by_remote_plugin_id)
|
||||
}
|
||||
|
||||
fn read_plugin_share_local_paths_for_update(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<BTreeMap<String, AbsolutePathBuf>> {
|
||||
match read_plugin_share_local_paths(codex_home) {
|
||||
Ok(mapping) => Ok(mapping),
|
||||
// This is a best-effort cache under .tmp, so malformed state should not
|
||||
// permanently block future share saves or deletes.
|
||||
Err(err) if err.kind() == io::ErrorKind::InvalidData => Ok(BTreeMap::new()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_paths(
|
||||
codex_home: &Path,
|
||||
mapping: BTreeMap<String, AbsolutePathBuf>,
|
||||
) -> io::Result<()> {
|
||||
let path = plugin_share_local_paths_path(codex_home);
|
||||
if mapping.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
let contents = serde_json::to_string_pretty(&PluginShareLocalPaths {
|
||||
local_plugin_paths_by_remote_plugin_id: mapping,
|
||||
})
|
||||
.map_err(io::Error::other)?;
|
||||
write_atomically(&path, &format!("{contents}\n"))
|
||||
}
|
||||
|
||||
fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
tmp.write_all(contents.as_bytes())?;
|
||||
tmp.persist(write_path).map_err(|err| err.error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf {
|
||||
codex_home.join(PLUGIN_SHARE_LOCAL_PATHS_FILE)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInterface;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -49,6 +50,25 @@ fn write_test_plugin(root: &Path, plugin_name: &str) -> PathBuf {
|
||||
plugin_path
|
||||
}
|
||||
|
||||
fn write_plugin_share_local_path_mapping(
|
||||
codex_home: &Path,
|
||||
remote_plugin_id: &str,
|
||||
plugin_path: &AbsolutePathBuf,
|
||||
) {
|
||||
write_file(
|
||||
&codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
||||
&format!(
|
||||
"{}\n",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"localPluginPathsByRemotePluginId": {
|
||||
remote_plugin_id: plugin_path,
|
||||
},
|
||||
}))
|
||||
.unwrap()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let decoder = flate2::read::GzDecoder::new(archive_bytes);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
@@ -87,6 +107,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_plugin_json_with_share_url(
|
||||
plugin_id: &str,
|
||||
share_url: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
unreachable!("plugin json should be an object");
|
||||
};
|
||||
fields.insert("share_url".to_string(), json!(share_url));
|
||||
plugin
|
||||
}
|
||||
|
||||
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
|
||||
let mut plugin = remote_plugin_json(plugin_id);
|
||||
let serde_json::Value::Object(fields) = &mut plugin else {
|
||||
@@ -127,9 +159,13 @@ fn expected_plugin_interface() -> PluginInterface {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -178,6 +214,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
/*remote_plugin_id*/ None,
|
||||
)
|
||||
@@ -191,6 +228,10 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::from([("plugins_123".to_string(), plugin_path)])
|
||||
);
|
||||
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
let upload_request = requests
|
||||
@@ -261,9 +302,13 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
|
||||
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
|
||||
let plugin_path =
|
||||
AbsolutePathBuf::try_from(write_test_plugin(temp_dir.path(), "demo-plugin")).unwrap();
|
||||
let archive_size = archive_plugin_for_upload(plugin_path.as_path())
|
||||
.unwrap()
|
||||
.len();
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -303,9 +348,15 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = save_remote_plugin_share(
|
||||
&config,
|
||||
Some(&auth),
|
||||
codex_home.path(),
|
||||
&plugin_path,
|
||||
Some("plugins_123"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
@@ -318,6 +369,10 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -332,7 +387,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param_is_missing("pageToken"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_123")],
|
||||
"plugins": [remote_plugin_json_with_share_url(
|
||||
"plugins_123",
|
||||
Some("https://chatgpt.example/plugins/share/share-key-1"),
|
||||
)],
|
||||
"pagination": {
|
||||
"next_page_token": "page-2"
|
||||
},
|
||||
@@ -350,7 +408,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
))
|
||||
.and(query_param("pageToken", "page-2"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"plugins": [remote_plugin_json("plugins_456")],
|
||||
"plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)],
|
||||
"pagination": empty_pagination_json(),
|
||||
})))
|
||||
.expect(1)
|
||||
@@ -367,30 +425,40 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth))
|
||||
let result = list_remote_plugin_shares(&config, Some(&auth), codex_home.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_123".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
|
||||
local_plugin_path: Some(local_plugin_path),
|
||||
},
|
||||
RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
RemotePluginShareSummary {
|
||||
summary: RemotePluginSummary {
|
||||
id: "plugins_456".to_string(),
|
||||
name: "demo-plugin".to_string(),
|
||||
installed: true,
|
||||
enabled: true,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
availability: PluginAvailability::Available,
|
||||
interface: Some(expected_plugin_interface()),
|
||||
},
|
||||
share_url: None,
|
||||
local_plugin_path: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
@@ -398,6 +466,10 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let local_plugin_path =
|
||||
AbsolutePathBuf::try_from(codex_home.path().join("local-plugin")).unwrap();
|
||||
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path);
|
||||
let server = MockServer::start().await;
|
||||
let config = test_config(&server);
|
||||
let auth = test_auth();
|
||||
@@ -411,7 +483,11 @@ async fn delete_remote_plugin_share_deletes_workspace_plugin() {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
|
||||
delete_remote_plugin_share(&config, Some(&auth), codex_home.path(), "plugins_123")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
local_paths::load_plugin_share_local_paths(codex_home.path()).unwrap(),
|
||||
BTreeMap::new()
|
||||
);
|
||||
}
|
||||
|
||||
+5
-5
@@ -3,9 +3,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::plugins::PluginsManager;
|
||||
use codex_core_plugins::startup_sync::has_local_curated_plugins_snapshot;
|
||||
use crate::manager::PluginsConfigInput;
|
||||
use crate::manager::PluginsManager;
|
||||
use crate::startup_sync::has_local_curated_plugins_snapshot;
|
||||
use codex_login::AuthManager;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
@@ -16,7 +16,7 @@ const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from
|
||||
pub(crate) fn start_startup_remote_plugin_sync_once(
|
||||
manager: Arc<PluginsManager>,
|
||||
codex_home: PathBuf,
|
||||
config: Config,
|
||||
config: PluginsConfigInput,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) {
|
||||
let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path());
|
||||
@@ -96,5 +96,5 @@ async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "startup_sync_tests.rs"]
|
||||
#[path = "startup_remote_sync_tests.rs"]
|
||||
mod tests;
|
||||
+9
-8
@@ -1,11 +1,12 @@
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
|
||||
use crate::PluginsManager;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
use crate::test_support::load_plugins_config;
|
||||
use crate::test_support::write_curated_plugin_sha;
|
||||
use crate::test_support::write_file;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -48,7 +49,7 @@ enabled = false
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
|
||||
let mut config = load_plugins_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
||||
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
|
||||
let auth_manager =
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::PluginsConfigInput;
|
||||
use codex_config::CloudRequirementsLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use toml::Value;
|
||||
|
||||
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
pub(crate) const TEST_CURATED_PLUGIN_CACHE_VERSION: &str = "01234567";
|
||||
|
||||
pub(crate) fn write_file(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) {
|
||||
let plugin_root = root.join("plugins").join(plugin_name);
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "{plugin_name}",
|
||||
"description": "Plugin that includes skills, MCP servers, and app connectors"
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/SKILL.md"),
|
||||
"---\nname: sample\ndescription: sample\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample-docs": {
|
||||
"type": "http",
|
||||
"url": "https://sample.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "connector_calendar"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
|
||||
let plugins = plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| {
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "{plugin_name}",
|
||||
"source": {{
|
||||
"source": "local",
|
||||
"path": "./plugins/{plugin_name}"
|
||||
}}
|
||||
}}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
write_file(
|
||||
&root.join(".agents/plugins/marketplace.json"),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
|
||||
"plugins": [
|
||||
{plugins}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
for plugin_name in plugin_names {
|
||||
write_curated_plugin(root, plugin_name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn write_curated_plugin_sha(codex_home: &Path) {
|
||||
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
|
||||
}
|
||||
|
||||
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
|
||||
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
|
||||
}
|
||||
|
||||
pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
||||
let codex_home = AbsolutePathBuf::try_from(codex_home).expect("codex home should be absolute");
|
||||
let cwd = AbsolutePathBuf::try_from(cwd).expect("cwd should be absolute");
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
codex_home.as_path(),
|
||||
Some(cwd),
|
||||
&[],
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::default(),
|
||||
&NoopThreadConfigLoader,
|
||||
)
|
||||
.await
|
||||
.expect("config should load");
|
||||
let effective_config = config_layer_stack.effective_config();
|
||||
PluginsConfigInput::new(
|
||||
config_layer_stack,
|
||||
feature_enabled(&effective_config, "plugins", /*default_enabled*/ true),
|
||||
feature_enabled(
|
||||
&effective_config,
|
||||
"remote_plugin",
|
||||
/*default_enabled*/ false,
|
||||
),
|
||||
feature_enabled(
|
||||
&effective_config,
|
||||
"plugin_hooks",
|
||||
/*default_enabled*/ false,
|
||||
),
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn feature_enabled(config: &Value, key: &str, default_enabled: bool) -> bool {
|
||||
config
|
||||
.get("features")
|
||||
.and_then(Value::as_table)
|
||||
.and_then(|features| features.get(key))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(default_enabled)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user