Merge origin/main into abhinav/spawn-agent-hook-alias

This commit is contained in:
Abhinav Vedmala
2026-05-21 15:40:49 -07:00
299 changed files with 7664 additions and 2795 deletions
+4 -10
View File
@@ -2434,6 +2434,7 @@ dependencies = [
"dunce",
"futures",
"gethostname",
"indexmap 2.13.0",
"libc",
"multimap",
"pretty_assertions",
@@ -2956,6 +2957,7 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-core",
"codex-extension-api",
"codex-protocol",
"codex-state",
@@ -3556,6 +3558,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-string",
"pretty_assertions",
"regex",
"serde",
"serde_json",
"tempfile",
@@ -3742,6 +3745,7 @@ dependencies = [
"async-trait",
"chrono",
"codex-git-utils",
"codex-install-context",
"codex-protocol",
"codex-rollout",
"codex-state",
@@ -4142,7 +4146,6 @@ dependencies = [
"tokio",
"windows 0.58.0",
"windows-sys 0.52.0",
"winres",
]
[[package]]
@@ -14727,15 +14730,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "winsafe"
version = "0.0.19"
@@ -3628,6 +3628,7 @@ async fn turn_event_counts_completed_tool_items() {
status: McpToolCallStatus::Completed,
arguments: json!({}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: None,
duration_ms: Some(2),
@@ -3861,6 +3861,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -6540,4 +6546,4 @@
}
],
"title": "ServerNotification"
}
}
@@ -7744,6 +7744,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/v2/SandboxMode"
@@ -11346,6 +11355,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -16311,6 +16328,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -17417,6 +17440,21 @@
"title": "ThreadRollbackResponse",
"type": "object"
},
"ThreadSearchResult": {
"properties": {
"snippet": {
"type": "string"
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
},
"required": [
"snippet",
"thread"
],
"type": "object"
},
"ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4113,6 +4113,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -7875,6 +7884,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -14135,6 +14152,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -15241,6 +15264,21 @@
"title": "ThreadRollbackResponse",
"type": "object"
},
"ThreadSearchResult": {
"properties": {
"snippet": {
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
},
"required": [
"snippet",
"thread"
],
"type": "object"
},
"ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -88,6 +88,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -43,6 +43,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
+5 -1
View File
@@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade
/**
* Deprecated: use `serviceTiers` instead.
*/
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>,
/**
* Catalog default service tier id for this model, when one is configured.
*/
defaultServiceTier: string | null, isDefault: boolean, };
@@ -53,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Thread } from "./Thread";
export type ThreadSearchResult = { thread: Thread, snippet: string, };
@@ -401,6 +401,7 @@ export type { ThreadResumeParams } from "./ThreadResumeParams";
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
export type { ThreadRollbackParams } from "./ThreadRollbackParams";
export type { ThreadRollbackResponse } from "./ThreadRollbackResponse";
export type { ThreadSearchResult } from "./ThreadSearchResult";
export type { ThreadSetNameParams } from "./ThreadSetNameParams";
export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadSettings } from "./ThreadSettings";
@@ -569,6 +569,12 @@ client_request_definitions! {
serialization: None,
response: v2::ThreadListResponse,
},
#[experimental("thread/search")]
ThreadSearch => "thread/search" {
params: v2::ThreadSearchParams,
serialization: None,
response: v2::ThreadSearchResponse,
},
ThreadLoadedList => "thread/loaded/list" {
params: v2::ThreadLoadedListParams,
serialization: None,
@@ -525,6 +525,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result: None,
error: None,
duration_ms: None,
@@ -566,6 +567,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result,
error,
duration_ms,
@@ -1911,6 +1913,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1960,6 +1963,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1986,6 +1990,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -2016,6 +2021,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -386,6 +386,7 @@ pub struct ConfigRequirements {
#[experimental("configRequirements/read.allowedApprovalsReviewers")]
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub computer_use: Option<ComputerUseRequirements>,
@@ -286,6 +286,7 @@ pub enum ThreadItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
@@ -846,6 +847,7 @@ impl From<CoreTurnItem> for ThreadItem {
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
error: mcp.error.map(McpToolCallError::from),
duration_ms,
@@ -98,6 +98,9 @@ pub struct Model {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model, when one is configured.
#[serde(default)]
pub default_service_tier: Option<String>,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -1728,6 +1728,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
}]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -2491,6 +2492,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
error: None,
@@ -2506,6 +2508,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -2518,6 +2521,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
@@ -2538,6 +2542,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
@@ -973,6 +973,34 @@ pub struct ThreadListParams {
pub search_term: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchParams {
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional sort key; defaults to created_at.
#[ts(optional = nullable)]
pub sort_key: Option<ThreadSortKey>,
/// Optional sort direction; defaults to descending (newest first).
#[ts(optional = nullable)]
pub sort_direction: Option<SortDirection>,
/// Optional source filter; when set, only sessions from these source kinds
/// are returned. When omitted or empty, defaults to interactive sources.
#[ts(optional = nullable)]
pub source_kinds: Option<Vec<ThreadSourceKind>>,
/// Optional archived filter; when set to true, only archived threads are returned.
/// If false or null, only non-archived threads are returned.
#[ts(optional = nullable)]
pub archived: Option<bool>,
/// Required substring/full-text query for thread search.
pub search_term: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum ThreadListCwdFilter {
@@ -1029,6 +1057,29 @@ pub struct ThreadListResponse {
pub backwards_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchResult {
pub thread: Thread,
pub snippet: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchResponse {
pub data: Vec<ThreadSearchResult>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// if None, there are no more items to return.
pub next_cursor: Option<String>,
/// Opaque cursor to pass as `cursor` when reversing `sortDirection`.
/// This is only populated when the page contains at least one thread.
/// Use it with the opposite `sortDirection`; for timestamp sorts it anchors
/// at the start of the page timestamp so same-second updates are not skipped.
pub backwards_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1339,7 +1339,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
installation_id: TEST_INSTALLATION_ID.to_string(),
},
Some(state_db.clone()),
auth_manager,
auth_manager.clone(),
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
@@ -1358,8 +1358,11 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
AuthCredentialsStoreMode::File,
)
.expect("auth with account id should save");
auth_manager.reload().await;
let enroll_request = accept_http_request(&listener).await;
let enroll_request = timeout(Duration::from_millis(100), accept_http_request(&listener))
.await
.expect("auth change should wake remote control before the retry delay");
assert_eq!(
enroll_request.request_line,
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
@@ -226,6 +226,7 @@ pub(crate) struct RemoteControlWebsocket {
reconnect_attempt: u64,
enrollment: Option<RemoteControlEnrollment>,
auth_recovery: UnauthorizedRecovery,
auth_change_rx: watch::Receiver<u64>,
client_tracker: Arc<Mutex<ClientTracker>>,
state: Arc<Mutex<WebsocketState>>,
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
@@ -240,6 +241,12 @@ pub(crate) struct RemoteControlWebsocketConfig {
pub(crate) server_name: String,
}
pub(super) struct RemoteControlAuthContext<'a> {
auth_manager: &'a Arc<AuthManager>,
auth_recovery: &'a mut UnauthorizedRecovery,
auth_change_rx: &'a mut watch::Receiver<u64>,
}
enum ConnectOutcome {
Connected(Box<WebSocketStream<MaybeTlsStream<TcpStream>>>),
Disabled,
@@ -321,6 +328,7 @@ impl RemoteControlWebsocket {
);
let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let auth_recovery = auth_manager.unauthorized_recovery();
let auth_change_rx = auth_manager.auth_change_receiver();
Self {
remote_control_url: config.remote_control_url,
@@ -334,6 +342,7 @@ impl RemoteControlWebsocket {
reconnect_attempt: 0,
enrollment: None,
auth_recovery,
auth_change_rx,
client_tracker: Arc::new(Mutex::new(client_tracker)),
state: Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
@@ -457,6 +466,11 @@ impl RemoteControlWebsocket {
subscribe_cursor: subscribe_cursor.as_deref(),
app_server_client_name,
};
let auth_context = RemoteControlAuthContext {
auth_manager: &self.auth_manager,
auth_recovery: &mut self.auth_recovery,
auth_change_rx: &mut self.auth_change_rx,
};
let connect_result = tokio::select! {
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
@@ -468,8 +482,7 @@ impl RemoteControlWebsocket {
connect_result = connect_remote_control_websocket(
&remote_control_target,
self.state_db.as_deref(),
&self.auth_manager,
&mut self.auth_recovery,
auth_context,
&mut self.enrollment,
connect_options,
&self.status_publisher,
@@ -517,6 +530,14 @@ impl RemoteControlWebsocket {
}
return ConnectOutcome::Disabled;
}
changed = self.auth_change_rx.changed() => {
if changed.is_err() {
return ConnectOutcome::Shutdown;
}
self.auth_recovery = self.auth_manager.unauthorized_recovery();
self.reconnect_attempt = 0;
info!("retrying app-server remote control websocket after auth changed");
}
_ = tokio::time::sleep(reconnect_delay) => {}
}
}
@@ -1018,8 +1039,7 @@ pub(crate) async fn load_remote_control_auth(
pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
auth_manager: &Arc<AuthManager>,
auth_recovery: &mut UnauthorizedRecovery,
auth_context: RemoteControlAuthContext<'_>,
enrollment: &mut Option<RemoteControlEnrollment>,
connect_options: RemoteControlConnectOptions<'_>,
status_publisher: &RemoteControlStatusPublisher,
@@ -1028,6 +1048,11 @@ pub(super) async fn connect_remote_control_websocket(
tungstenite::http::Response<()>,
)> {
ensure_rustls_crypto_provider();
let RemoteControlAuthContext {
auth_manager,
auth_recovery,
auth_change_rx,
} = auth_context;
let Some(state_db) = state_db else {
*enrollment = None;
@@ -1098,7 +1123,7 @@ pub(super) async fn connect_remote_control_websocket(
Ok(new_enrollment) => new_enrollment,
Err(err)
if err.kind() == ErrorKind::PermissionDenied
&& recover_remote_control_auth(auth_recovery).await =>
&& recover_remote_control_auth(auth_recovery, auth_change_rx).await =>
{
return Err(io::Error::other(format!(
"{err}; retrying after auth recovery"
@@ -1172,7 +1197,7 @@ pub(super) async fn connect_remote_control_websocket(
tungstenite::Error::Http(response)
if matches!(response.status().as_u16(), 401 | 403) =>
{
if recover_remote_control_auth(auth_recovery).await {
if recover_remote_control_auth(auth_recovery, auth_change_rx).await {
return Err(io::Error::other(format!(
"remote control websocket auth failed with HTTP {}; retrying after auth recovery",
response.status()
@@ -1191,15 +1216,25 @@ pub(super) async fn connect_remote_control_websocket(
}
}
async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool {
async fn recover_remote_control_auth(
auth_recovery: &mut UnauthorizedRecovery,
auth_change_rx: &mut watch::Receiver<u64>,
) -> bool {
if !auth_recovery.has_next() {
return false;
}
let mode = auth_recovery.mode_name();
let step = auth_recovery.step_name();
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
match auth_recovery.next().await {
Ok(step_result) => {
if step_result.auth_state_changed() == Some(true) {
mark_recovery_auth_change_seen(
auth_change_rx,
auth_change_revision_before_recovery,
);
}
info!(
"remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}",
step_result.auth_state_changed()
@@ -1213,6 +1248,20 @@ async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -
}
}
fn mark_recovery_auth_change_seen(
auth_change_rx: &mut watch::Receiver<u64>,
auth_change_revision_before_recovery: u64,
) {
let auth_change_revision_after_recovery = *auth_change_rx.borrow();
if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) {
// Recovery updated the same watch that wakes the outer reconnect
// loop. Mark only that single revision seen; if more revisions
// arrived while recovery was in flight, leave them pending so the
// reconnect loop still reacts to the later external auth change.
auth_change_rx.borrow_and_update();
}
}
fn format_remote_control_websocket_connect_error(
websocket_url: &str,
err: &tungstenite::Error,
@@ -1290,6 +1339,37 @@ mod tests {
(RemoteControlStatusPublisher::new(status_tx), status_rx)
}
#[test]
fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() {
let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64);
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
auth_change_tx.send_modify(|revision| *revision += 1);
mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open")
);
}
#[test]
fn mark_recovery_auth_change_seen_preserves_racing_auth_change() {
let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64);
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
auth_change_tx.send_modify(|revision| *revision += 1);
auth_change_tx.send_modify(|revision| *revision += 1);
mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery);
assert!(
auth_change_rx
.has_changed()
.expect("auth change watch should remain open")
);
}
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
.await
@@ -1375,6 +1455,7 @@ mod tests {
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1386,8 +1467,11 @@ mod tests {
let err = match connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1440,6 +1524,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1466,8 +1551,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1503,6 +1591,12 @@ mod tests {
.expect("token should be readable"),
"fresh-token"
);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open"),
"recovery's own auth reload should not wake the reconnect loop"
);
}
#[tokio::test]
@@ -1538,6 +1632,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = None;
let (status_publisher, status_rx) = remote_control_status_channel();
save_auth(
@@ -1550,8 +1645,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1585,6 +1683,12 @@ mod tests {
.expect("token should be readable"),
"fresh-token"
);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open"),
"recovery's own auth reload should not wake the reconnect loop"
);
}
#[tokio::test]
@@ -1593,6 +1697,7 @@ mod tests {
.expect("target should parse");
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1604,8 +1709,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
/*state_db*/ None,
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1637,6 +1745,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1653,8 +1762,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
+3 -3
View File
@@ -188,7 +188,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
@@ -226,7 +226,7 @@ Example with notification opt-out:
- `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; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1231,7 +1231,7 @@ Today both notifications carry an empty `items` array even when item events were
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
"Codex guidance"
);
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
CI = "false"
FOO = "bar"
MAX_RETRIES = "3"
MY_TEAM = "codex"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
@@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() {
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "local"
LOCAL_ONLY = "true"
PROJECT_ONLY = "yes"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
+1 -2
View File
@@ -786,8 +786,7 @@ pub async fn run_main_with_transport_options(
});
let processor_handle = tokio::spawn({
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let auth_manager = Arc::clone(&auth_manager);
let analytics_events_client =
analytics_events_client_from_config(Arc::clone(&auth_manager), &config);
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(
@@ -1072,6 +1072,9 @@ impl MessageProcessor {
ClientRequest::ThreadList { params, .. } => {
self.thread_processor.thread_list(params).await
}
ClientRequest::ThreadSearch { params, .. } => {
self.thread_processor.thread_search(params).await
}
ClientRequest::ThreadLoadedList { params, .. } => {
self.thread_processor.thread_loaded_list(params).await
}
+1
View File
@@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
description: service_tier.description,
})
.collect(),
default_service_tier: preset.default_service_tier,
is_default: preset.is_default,
}
}
@@ -214,6 +214,9 @@ use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSearchParams;
use codex_app_server_protocol::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSearchResult;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSettings;
@@ -340,12 +343,11 @@ use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::McpServerStatusSnapshot;
use codex_mcp::McpSnapshotDetail;
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
use codex_mcp::discover_supported_scopes;
use codex_mcp::effective_mcp_servers;
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
use codex_mcp::resolve_oauth_scopes;
use codex_memories_write::clear_memory_roots_contents;
@@ -410,6 +412,7 @@ use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
use codex_thread_store::SearchThreadsParams as StoreSearchThreadsParams;
use codex_thread_store::SortDirection as StoreSortDirection;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
@@ -171,7 +171,7 @@ impl AppsRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
&environment_manager,
Arc::clone(&environment_manager),
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
@@ -612,15 +612,10 @@ impl CatalogRequestProcessor {
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
let plugin_outcome = if plugins_enabled {
let plugins_input = config.plugins_config_input();
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&plugins_input,
/*plugin_hooks_feature_enabled*/ true,
)
.plugins_for_layer_stack(&config.config_layer_stack, &plugins_input)
.await
} else {
PluginLoadOutcome::default()
@@ -220,7 +220,7 @@ impl ConfigRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
&environment_manager,
Arc::clone(&environment_manager),
),
);
let all_connectors = match all_connectors_result {
@@ -419,6 +419,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_permissions: requirements.allowed_permissions,
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
@@ -638,10 +639,21 @@ mod tests {
#[test]
fn requirements_api_includes_allow_managed_hooks_only() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allowed_permissions: Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
]),
allow_managed_hooks_only: Some(true),
..ConfigRequirementsToml::default()
});
assert_eq!(
mapped.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(mapped.hooks, None);
}
@@ -205,24 +205,20 @@ impl McpRequestProcessor {
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// Status listing has no turn cwd. Prefer the configured default env,
// then configured local if present; do not manufacture a hidden local
// env in no-local modes.
let runtime_environment = McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
);
// This threadless status path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
// environment stdio MCPs must declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
tokio::spawn(async move {
Self::list_mcp_server_status_task(
outgoing,
request,
params,
config,
mcp_config,
auth,
runtime_environment,
runtime_context,
)
.await;
});
@@ -233,18 +229,16 @@ impl McpRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
) {
let result = Self::list_mcp_server_status_response(
request_id.request_id.to_string(),
params,
config,
mcp_config,
auth,
runtime_environment,
runtime_context,
)
.await;
outgoing.send_result(request_id, result).await;
@@ -253,10 +247,9 @@ impl McpRequestProcessor {
async fn list_mcp_server_status_response(
request_id: String,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
@@ -267,31 +260,25 @@ impl McpRequestProcessor {
&mcp_config,
auth.as_ref(),
request_id,
runtime_environment,
runtime_context,
detail,
)
.await;
let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
let McpServerStatusSnapshot {
tools_by_server,
resources,
resource_templates,
auth_statuses,
mut server_names,
} = snapshot;
let mut server_names: Vec<String> = config
.mcp_servers
.keys()
.cloned()
// Include runtime-added/plugin MCP servers that are present in the
// effective runtime config even when they are not user-declared in
// `config.mcp_servers`.
.chain(effective_servers.keys().cloned())
.chain(auth_statuses.keys().cloned())
.chain(resources.keys().cloned())
.chain(resource_templates.keys().cloned())
.collect();
server_names.extend(
auth_statuses
.keys()
.cloned()
.chain(resources.keys().cloned())
.chain(resource_templates.keys().cloned()),
);
server_names.sort();
server_names.dedup();
@@ -367,21 +354,18 @@ impl McpRequestProcessor {
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// Resource reads without a thread have no turn cwd. Prefer the
// configured default env, then configured local if present; do not
// manufacture a hidden local env in no-local modes.
let runtime_environment = McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
);
// This threadless resource-read path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
// environment stdio MCPs must declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
let request_id = request_id.clone();
tokio::spawn(async move {
let result = read_mcp_resource_without_thread(
&mcp_config,
auth.as_ref(),
runtime_environment,
runtime_context,
&server,
&uri,
)
@@ -959,9 +959,12 @@ impl PluginRequestProcessor {
None => None,
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries =
load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager)
.await;
let app_summaries = load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
)
.await;
let visible_skills = outcome
.plugin
.skills
@@ -1037,8 +1040,12 @@ impl PluginRequestProcessor {
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries =
load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await;
let app_summaries = load_plugin_app_summaries(
&config,
&plugin_apps,
Arc::clone(&environment_manager),
)
.await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
@@ -1506,7 +1513,7 @@ impl PluginRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
/*force_refetch*/ true,
&environment_manager
Arc::clone(&environment_manager)
),
);
@@ -1772,7 +1779,7 @@ impl PluginRequestProcessor {
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: &EnvironmentManager,
environment_manager: Arc<EnvironmentManager>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
@@ -589,6 +589,15 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_search(
&self,
params: ThreadSearchParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_search_response_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_loaded_list(
&self,
params: ThreadLoadedListParams,
@@ -1861,6 +1870,131 @@ impl ThreadRequestProcessor {
})
}
async fn thread_search_response_inner(
&self,
params: ThreadSearchParams,
) -> Result<ThreadSearchResponse, JSONRPCErrorError> {
let ThreadSearchParams {
cursor,
limit,
sort_key,
sort_direction,
source_kinds,
archived,
search_term,
} = params;
let search_term = search_term.trim().to_string();
let search_term = (!search_term.is_empty())
.then_some(search_term)
.ok_or_else(|| invalid_request("thread/search requires a non-empty searchTerm"))?;
let requested_page_size = limit
.map(|value| value as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, THREAD_LIST_MAX_LIMIT);
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
};
let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc);
let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds);
let mut cursor_obj = cursor;
let mut last_cursor = cursor_obj.clone();
let mut remaining = requested_page_size;
let mut search_results = Vec::with_capacity(requested_page_size);
let mut next_cursor = None;
while remaining > 0 {
let page = self
.thread_store
.search_threads(StoreSearchThreadsParams {
page_size: remaining.min(THREAD_LIST_MAX_LIMIT),
cursor: cursor_obj.clone(),
sort_key: store_sort_key,
sort_direction: match store_sort_direction {
SortDirection::Asc => StoreSortDirection::Asc,
SortDirection::Desc => StoreSortDirection::Desc,
},
allowed_sources: allowed_sources.clone(),
archived: archived.unwrap_or(false),
search_term: search_term.clone(),
})
.await
.map_err(thread_store_list_error)?;
for result in page.items {
let source = with_thread_spawn_agent_metadata(
result.thread.source.clone(),
result.thread.agent_nickname.clone(),
result.thread.agent_role.clone(),
);
if source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&source, filter))
{
search_results.push(result);
if search_results.len() >= requested_page_size {
break;
}
}
}
remaining = requested_page_size.saturating_sub(search_results.len());
next_cursor = page.next_cursor;
if remaining == 0 {
break;
}
let Some(cursor_val) = next_cursor.clone() else {
break;
};
if last_cursor.as_ref() == Some(&cursor_val) {
next_cursor = None;
break;
}
last_cursor = Some(cursor_val.clone());
cursor_obj = Some(cursor_val);
}
let backwards_cursor = search_results.first().and_then(|result| {
thread_backwards_cursor_for_sort_key(
&result.thread,
store_sort_key,
store_sort_direction,
)
});
let fallback_provider = self.config.model_provider_id.clone();
let mut results = Vec::with_capacity(search_results.len());
let mut status_ids = Vec::with_capacity(search_results.len());
for result in search_results {
let (thread, _) = thread_from_stored_thread(
result.thread,
fallback_provider.as_str(),
&self.config.cwd,
);
status_ids.push(thread.id.clone());
results.push((thread, result.snippet));
}
let statuses = self
.thread_watch_manager
.loaded_statuses_for_threads(status_ids)
.await;
let data = results
.into_iter()
.map(|(mut thread, snippet)| {
if let Some(status) = statuses.get(&thread.id) {
thread.status = status.clone();
}
ThreadSearchResult { thread, snippet }
})
.collect();
Ok(ThreadSearchResponse {
data,
next_cursor,
backwards_cursor,
})
}
async fn thread_loaded_list_response_inner(
&self,
params: ThreadLoadedListParams,
@@ -79,6 +79,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -120,6 +121,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(redacted_mcp_tool_call_result())),
error: None,
duration_ms: Some(8),
@@ -136,6 +138,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "secret error".to_string(),
@@ -154,6 +157,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: REDACTED_PAYLOAD.to_string(),
@@ -88,6 +88,7 @@ use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSearchParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadShellCommandParams;
@@ -508,6 +509,15 @@ impl McpProcess {
self.send_request("thread/list", params).await
}
/// Send a `thread/search` JSON-RPC request.
pub async fn send_thread_search_request(
&mut self,
params: ThreadSearchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/search", params).await
}
/// Send a `thread/loaded/list` JSON-RPC request.
pub async fn send_thread_loaded_list_request(
&mut self,
@@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset.service_tiers.clone(),
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -97,7 +97,6 @@ fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> R
codex_home.join("config.toml"),
r#"[features]
plugins = true
plugin_hooks = true
hooks = true
[plugins."demo@test"]
@@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp"
status,
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(result),
error: None,
duration_ms: None,
@@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
description: service_tier.description.clone(),
})
.collect(),
default_service_tier: preset.default_service_tier.clone(),
is_default: preset.is_default,
}
}
@@ -1273,7 +1273,6 @@ description: Visible only for ChatGPT
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
plugin_hooks = true
[[skills.config]]
name = "demo-plugin:thread-summarizer"
@@ -17,6 +17,7 @@ use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@@ -660,6 +661,161 @@ sqlite = true
Ok(())
}
#[tokio::test]
async fn thread_search_returns_content_matches() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let older_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"match: needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _non_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"no hit here",
Some("mock_provider"),
/*git_info*/ None,
)?;
let newer_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"mixed NEEDLE suffix",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: "needle".to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse {
data, next_cursor, ..
} = to_response::<ThreadSearchResponse>(resp)?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![newer_match, older_match]);
assert_eq!(data[0].snippet, "mixed NEEDLE suffix");
Ok(())
}
#[tokio::test]
async fn thread_search_matches_json_escaped_content() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let search_term = r#"quoted "needle" \ path"#;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
search_term,
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: search_term.to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].thread.id, thread_id);
assert_eq!(data[0].snippet, search_term);
Ok(())
}
#[tokio::test]
async fn thread_search_filters_by_source_kind() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: Some(vec![ThreadSourceKind::Exec]),
archived: None,
search_term: "needle".to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![exec_id.as_str()]);
assert_ne!(cli_id, exec_id);
Ok(())
}
#[tokio::test]
async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -679,6 +679,7 @@ fn append_resume_redaction_history(
arguments: Some(json!({"secret":"argument"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: None,
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![json!({
@@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -136,7 +137,7 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res
}
#[tokio::test]
async fn thread_settings_update_clears_service_tier() -> Result<()> {
async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
@@ -181,7 +182,10 @@ async fn thread_settings_update_clears_service_tier() -> Result<()> {
let clear_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(clear_updated.thread_id, thread.id);
assert_eq!(clear_updated.thread_settings.model, model_id);
assert_eq!(clear_updated.thread_settings.service_tier, None);
assert_eq!(
clear_updated.thread_settings.service_tier.as_deref(),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE)
);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
@@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -484,7 +485,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(service_tier_id));
// Unsupported catalog ids are dropped at session config time instead of echoed back.
assert_eq!(service_tier, None);
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_default_service_tier() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
@@ -1004,10 +1004,14 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
assert!(
err.error
.message
.contains("invalid thread settings override")
.contains("`approval_policy = \"never\"` cannot be used"),
"unexpected error message: {}",
err.error.message
);
assert!(
err.error.message.contains("allowed set [ReadOnly]"),
err.error
.message
.contains("requirements do not allow `sandbox_mode = \"danger-full-access\"`"),
"unexpected error message: {}",
err.error.message
);
-1
View File
@@ -496,7 +496,6 @@ fn config_overrides_from_interactive(
};
ConfigOverrides {
model: interactive.model.clone(),
config_profile: interactive.config_profile.clone(),
approval_policy,
sandbox_mode,
cwd: interactive.cwd.clone(),
+16 -61
View File
@@ -1331,7 +1331,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"debug clear-memories",
)?;
run_debug_clear_memories_command(&root_config_overrides, &interactive).await?;
run_debug_clear_memories_command(&root_config_overrides).await?;
}
},
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
@@ -1381,14 +1381,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
"exec-server",
)?;
let strict_config = cmd.strict_config || root_strict_config;
run_exec_server_command(
cmd,
&arg0_paths,
&root_config_overrides,
interactive.config_profile.clone(),
strict_config,
)
.await?;
run_exec_server_command(cmd, &arg0_paths, &root_config_overrides, strict_config)
.await?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
@@ -1397,7 +1391,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features list",
)?;
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
@@ -1410,15 +1403,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
));
}
// Thread through relevant top-level flags (at minimum, `--profile`).
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let mut rows = Vec::with_capacity(FEATURES.len());
@@ -1444,7 +1430,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features enable",
)?;
enable_feature_in_config(&interactive, &feature).await?;
enable_feature_in_config(&feature).await?;
}
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
reject_remote_mode_for_subcommand(
@@ -1452,7 +1438,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features disable",
)?;
disable_feature_in_config(&interactive, &feature).await?;
disable_feature_in_config(&feature).await?;
}
},
}
@@ -1477,7 +1463,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`."
"--profile only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`."
),
}
}
@@ -1486,7 +1472,6 @@ async fn run_exec_server_command(
cmd: ExecServerCommand,
arg0_paths: &Arg0DispatchPaths,
root_config_overrides: &CliConfigOverrides,
config_profile: Option<String>,
strict_config: bool,
) -> anyhow::Result<()> {
let codex_self_exe = arg0_paths
@@ -1501,8 +1486,7 @@ async fn run_exec_server_command(
let environment_id = cmd
.environment_id
.ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?;
let config =
load_exec_server_config(root_config_overrides, config_profile, strict_config).await?;
let config = load_exec_server_config(root_config_overrides, strict_config).await?;
let auth_provider =
load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?;
let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new(
@@ -1520,8 +1504,7 @@ async fn run_exec_server_command(
// Local exec-server startup does not consume Config, but strict
// mode should still reject unknown fields before opening a listener.
let _validated_config =
load_exec_server_config(root_config_overrides, config_profile, strict_config)
.await?;
load_exec_server_config(root_config_overrides, strict_config).await?;
}
let listen_url = cmd
.listen
@@ -1564,7 +1547,6 @@ async fn load_exec_server_remote_auth_provider(
async fn load_exec_server_config(
root_config_overrides: &CliConfigOverrides,
config_profile: Option<String>,
strict_config: bool,
) -> anyhow::Result<codex_core::config::Config> {
let cli_kv_overrides = root_config_overrides
@@ -1572,10 +1554,6 @@ async fn load_exec_server_config(
.map_err(anyhow::Error::msg)?;
Ok(ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(ConfigOverrides {
config_profile,
..Default::default()
})
.strict_config(strict_config)
.build()
.await?)
@@ -1602,24 +1580,22 @@ async fn load_exec_server_remote_auth(
Ok(auth)
}
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
async fn enable_feature_in_config(feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, /*enabled*/ true)
.apply()
.await?;
println!("Enabled feature `{feature}` in config.toml.");
maybe_print_under_development_feature_warning(&codex_home, interactive, feature);
maybe_print_under_development_feature_warning(&codex_home, feature);
Ok(())
}
async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
async fn disable_feature_in_config(feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, /*enabled*/ false)
.apply()
.await?;
@@ -1643,15 +1619,7 @@ fn loader_overrides_for_profile(
}
}
fn maybe_print_under_development_feature_warning(
codex_home: &std::path::Path,
interactive: &TuiCli,
feature: &str,
) {
if interactive.config_profile.is_some() {
return;
}
fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) {
let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else {
return;
};
@@ -1709,7 +1677,6 @@ async fn run_debug_prompt_input_command(
};
let overrides = ConfigOverrides {
model: shared.model,
config_profile: shared.config_profile,
approval_policy,
sandbox_mode,
cwd: shared.cwd,
@@ -1777,18 +1744,12 @@ async fn run_debug_models_command(
async fn run_debug_clear_memories_command(
root_config_overrides: &CliConfigOverrides,
interactive: &TuiCli,
) -> anyhow::Result<()> {
let cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
@@ -2286,21 +2247,19 @@ mod tests {
#[test]
fn profile_v2_is_rejected_for_config_management_subcommands() {
assert!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err()
);
assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err());
}
#[test]
fn profile_v2_is_allowed_for_runtime_subcommands() {
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"])
profile_v2_for_args(&["codex", "--profile", "work", "resume"])
.expect("resume supports profile-v2")
.as_deref(),
Some("work")
);
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"])
profile_v2_for_args(&["codex", "--profile", "work", "debug", "prompt-input"])
.expect("debug prompt-input supports profile-v2")
.as_deref(),
Some("work")
@@ -2310,8 +2269,7 @@ mod tests {
#[test]
fn profile_v2_rejects_non_plain_names_at_parse_time() {
assert!(
MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"])
.is_err()
MultitoolCli::try_parse_from(["codex", "--profile", "nested/work", "resume"]).is_err()
);
}
@@ -2762,8 +2720,6 @@ mod tests {
"-m",
"gpt-5.1-test",
"-p",
"my-profile",
"--profile-v2",
"my-config",
"-C",
"/tmp",
@@ -2776,7 +2732,6 @@ mod tests {
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert!(interactive.oss);
assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config"));
assert_matches!(
interactive.sandbox_mode,
+70 -31
View File
@@ -6,14 +6,15 @@ 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::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::marketplace_root_dir;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::collections::HashSet;
use crate::plugin_cmd::configured_marketplace_snapshot_issues;
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace")]
@@ -30,7 +31,7 @@ enum MarketplaceSubcommand {
/// Add a local or Git marketplace to the configured marketplace sources.
Add(AddMarketplaceArgs),
/// List configured marketplace names and their local snapshot roots.
/// List plugin marketplaces Codex is currently considering and their roots.
List,
/// Refresh configured Git marketplace snapshots.
@@ -150,39 +151,77 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_active_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
let manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let marketplace_listing = manager
.discover_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces")?;
let mut load_issues = configured_marketplace_snapshot_issues(
config.codex_home.as_path(),
&plugins_input,
&marketplace_listing.errors,
/*marketplace_name*/ None,
);
let mut issue_paths = load_issues
.iter()
.map(|issue| issue.path.clone())
.collect::<HashSet<_>>();
for error in &marketplace_listing.errors {
if issue_paths.insert(error.path.to_path_buf()) {
load_issues.push(crate::plugin_cmd::ConfiguredMarketplaceSnapshotIssue {
marketplace_name: error.path.display().to_string(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
if !load_issues.is_empty() {
let issue_lines = load_issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load marketplace(s):\n{issue_lines}");
}
let marketplaces = marketplace_listing.marketplaces;
if marketplaces.is_empty() {
println!("No plugin marketplaces in scope.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
let mut seen_roots = HashSet::new();
let mut rows = Vec::new();
for marketplace in marketplaces {
let Ok(root) = marketplace_root_dir(&marketplace.path) else {
continue;
};
if !seen_roots.insert(root.clone()) {
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
rows.push((marketplace.name, root));
}
let marketplace_width = rows
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or("MARKETPLACE".len())
.max("MARKETPLACE".len());
println!("{:<marketplace_width$} ROOT", "MARKETPLACE");
for (marketplace_name, root) in rows {
println!(
"{:<marketplace_width$} {}",
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
root.display()
);
}
Ok(())
+1 -1
View File
@@ -301,7 +301,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let new_entry = McpServerConfig {
transport: transport.clone(),
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+33 -10
View File
@@ -359,10 +359,10 @@ fn find_marketplace_for_plugin(
}
}
struct ConfiguredMarketplaceSnapshotIssue {
marketplace_name: String,
path: PathBuf,
message: String,
pub(crate) struct ConfiguredMarketplaceSnapshotIssue {
pub(crate) marketplace_name: String,
pub(crate) path: PathBuf,
pub(crate) message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
@@ -396,17 +396,16 @@ fn ensure_configured_marketplace_snapshots_loaded(
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
fn configured_marketplace_snapshot_issues(
pub(crate) fn configured_marketplace_snapshot_issues(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Vec<ConfiguredMarketplaceSnapshotIssue> {
let Some(user_layer) = plugins_input.config_layer_stack.get_active_user_layer() else {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_layer
.config
let Some(configured_marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
@@ -420,9 +419,33 @@ fn configured_marketplace_snapshot_issues(
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table()
|| validate_plugin_segment(configured_name, "marketplace name").is_err()
if !marketplace.is_table() {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: "configured marketplace entry must be a table".to_string(),
});
continue;
}
if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: err.to_string(),
});
continue;
}
if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local")
&& marketplace
.get("source")
.and_then(toml::Value::as_str)
.is_none_or(str::is_empty)
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid source>"),
message: "configured local marketplace source is missing or empty".to_string(),
});
continue;
}
let Some(root) = resolve_configured_marketplace_root(
+269 -36
View File
@@ -7,9 +7,21 @@ use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
const MARKETPLACE_HEADER: &str = "MARKETPLACE";
const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT";
fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String {
format!(
"{marketplace_name:<width$} {}",
root.display(),
width = MARKETPLACE_HEADER.len()
)
}
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
cmd.env("HOME", codex_home);
Ok(cmd)
}
@@ -40,7 +52,7 @@ plugins = true
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents").join("plugins"))?;
std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?;
std::fs::write(
@@ -48,6 +60,22 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
.join(".agents")
.join("plugins")
.join("marketplace.json"),
marketplace_manifest,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
@@ -60,16 +88,28 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
}
]
}"#,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
)
}
fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"products": []
}
}
]
}"#,
)
}
fn setup_local_marketplace() -> Result<(TempDir, TempDir)> {
@@ -94,6 +134,20 @@ fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> {
Ok((codex_home, source))
}
fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source_with_explicit_empty_products(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
@@ -207,16 +261,203 @@ fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result
Ok(())
}
fn setup_configured_local_marketplace_with_missing_source() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
source_type = "local"
"#,
)?;
Ok(codex_home)
}
fn setup_configured_local_marketplace_with_invalid_name() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces."bad/name"]
source_type = "local"
source = "/tmp/debug"
"#,
)?;
Ok(codex_home)
}
fn assert_configured_marketplace_snapshot_failure(
assert: assert_cmd::assert::Assert,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
fn assert_marketplace_failure(
assert: assert_cmd::assert::Assert,
marketplace_name: &str,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(format!("`{marketplace_name}`")))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_marketplace_source(home.path())?;
write_plugins_enabled_config(codex_home.path())?;
let expected_row = marketplace_list_row("debug", home.path());
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> {
let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> {
let codex_home = setup_configured_local_marketplace_with_invalid_name()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"bad/name",
Path::new("<invalid config>"),
"marketplace name",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()>
{
let codex_home = setup_configured_local_marketplace_with_missing_source()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains("`debug`"))
.stderr(contains("<invalid source>"))
.stderr(contains(
"configured local marketplace source is missing or empty",
));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(home.path().join(".agents/plugins"))?;
let home_marketplace_path = home
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json");
std::fs::write(&home_marketplace_path, "{not valid json")?;
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(home_marketplace_path.display().to_string()))
.stderr(contains("key must be a string"));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"key must be a string",
);
Ok(())
}
@@ -286,18 +527,13 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<(
async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.path().display().to_string()))
.stderr(contains(
"marketplace root does not contain a supported manifest",
));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert(),
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
@@ -414,18 +650,15 @@ async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()>
#[tokio::test]
async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?;
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains("invalid marketplace file"))
.stderr(contains("key must be a string"));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert(),
source.path(),
"key must be a string",
);
Ok(())
}
+16
View File
@@ -1216,6 +1216,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1300,6 +1301,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1335,6 +1337,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1387,6 +1390,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1568,6 +1572,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1650,6 +1655,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1730,6 +1736,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1938,6 +1945,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1980,6 +1988,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2042,6 +2051,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2100,6 +2110,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2160,6 +2171,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2221,6 +2233,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2286,6 +2299,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2377,6 +2391,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2414,6 +2429,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -993,6 +993,22 @@ mod tests {
);
}
#[test]
fn parse_v1_input_transcript_turn_marked_event() {
let payload = json!({
"type": "conversation.input_transcript.turn_marked",
"transcript": "hello realtime"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone {
text: "hello realtime".to_string(),
}))
);
}
#[test]
fn parse_output_transcript_delta_event() {
let payload = json!({
@@ -1581,6 +1597,10 @@ mod tests {
.expect("text");
let third_json: Value = serde_json::from_str(&third).expect("json");
assert_eq!(third_json["type"], "conversation.item.create");
assert_eq!(
third_json["item"]["content"][0]["type"],
Value::String("input_text".to_string())
);
assert_eq!(third_json["item"]["content"][0]["text"], "hello agent");
let fourth = ws
@@ -21,7 +21,7 @@ pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutbound
r#type: ConversationItemType::Message,
role: ConversationRole::User,
content: vec![ConversationItemContent {
r#type: ConversationContentType::Text,
r#type: ConversationContentType::InputText,
text,
}],
}),
@@ -199,7 +199,6 @@ pub(super) struct ConversationItemContent {
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum ConversationContentType {
Text,
InputText,
}
@@ -43,7 +43,8 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option<RealtimeEvent> {
| "conversation.item.input_audio_transcription.delta" => {
parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta)
}
"conversation.item.input_audio_transcription.completed" => {
"conversation.input_transcript.turn_marked"
| "conversation.item.input_audio_transcription.completed" => {
parse_transcript_done_event(&parsed, "transcript")
.map(RealtimeEvent::InputTranscriptDone)
}
@@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+3 -3
View File
@@ -27,7 +27,7 @@ use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError;
use crate::rmcp_client::list_tools_for_client_uncached;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::runtime::emit_duration;
use crate::server::EffectiveMcpServer;
use crate::server::McpServerMetadata;
@@ -176,7 +176,7 @@ impl McpConnectionManager {
submit_id: String,
tx_event: Sender<Event>,
initial_permission_profile: PermissionProfile,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
host_owned_codex_apps_enabled: bool,
@@ -248,7 +248,7 @@ impl McpConnectionManager {
elicitation_requests.clone(),
codex_apps_tools_cache_context,
Arc::clone(&tool_plugin_provenance),
runtime_environment.clone(),
runtime_context.clone(),
runtime_auth_provider,
client_elicitation_capability.clone(),
);
@@ -18,6 +18,7 @@ use crate::tools::normalize_tools_for_model;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_exec_server::EnvironmentManager;
use codex_protocol::ToolName;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
@@ -864,7 +865,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -889,7 +890,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -915,9 +916,8 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
String::new(),
tx_event,
PermissionProfile::default(),
McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
),
codex_home.path().to_path_buf(),
@@ -988,7 +988,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1041,7 +1041,7 @@ fn mcp_init_error_display_reports_generic_errors() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+1 -1
View File
@@ -3,7 +3,7 @@ pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
pub use elicitation::ElicitationReviewerHandle;
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use runtime::McpRuntimeEnvironment;
pub use runtime::McpRuntimeContext;
pub use runtime::SandboxState;
pub use tools::ToolInfo;
+13 -6
View File
@@ -38,7 +38,7 @@ use serde_json::Value;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::server::EffectiveMcpServer;
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
@@ -261,7 +261,7 @@ pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
pub async fn read_mcp_resource(
config: &McpConfig,
auth: Option<&CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
server: &str,
uri: &str,
) -> anyhow::Result<ReadResourceResult> {
@@ -284,7 +284,7 @@ pub async fn read_mcp_resource(
String::new(),
tx_event,
PermissionProfile::default(),
runtime_environment,
runtime_context,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
@@ -314,13 +314,14 @@ pub struct McpServerStatusSnapshot {
pub resources: HashMap<String, Vec<Resource>>,
pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
pub auth_statuses: HashMap<String, McpAuthStatus>,
pub server_names: Vec<String>,
}
pub async fn collect_mcp_server_status_snapshot_with_detail(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth);
@@ -332,6 +333,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
resources: HashMap::new(),
resource_templates: HashMap::new(),
auth_statuses: HashMap::new(),
server_names: Vec::new(),
};
}
@@ -342,6 +344,8 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
)
.await;
let server_names = mcp_servers.keys().cloned().collect();
let (tx_event, rx_event) = unbounded();
drop(rx_event);
@@ -353,7 +357,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
submit_id,
tx_event,
PermissionProfile::default(),
runtime_environment,
runtime_context,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
@@ -367,6 +371,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
let snapshot = collect_mcp_server_status_snapshot_from_manager(
&mcp_connection_manager,
auth_status_entries,
server_names,
detail,
)
.await;
@@ -451,7 +456,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
http_headers,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -575,6 +580,7 @@ fn convert_mcp_resource_templates(
async fn collect_mcp_server_status_snapshot_from_manager(
mcp_connection_manager: &McpConnectionManager,
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
server_names: Vec<String>,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let (tools, resources, resource_templates) = tokio::join!(
@@ -613,6 +619,7 @@ async fn collect_mcp_server_status_snapshot_from_manager(
resources: convert_mcp_resources(resources),
resource_templates: convert_mcp_resource_templates(resource_templates),
auth_statuses: auth_statuses_from_entries(&auth_status_entries),
server_names,
}
}
+2 -2
View File
@@ -316,7 +316,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -341,7 +341,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+25 -46
View File
@@ -28,7 +28,7 @@ use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::runtime::emit_duration;
use crate::server::EffectiveMcpServer;
use crate::server::McpServerLaunch;
@@ -142,7 +142,7 @@ impl AsyncManagedClient {
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
client_elicitation_capability: ElicitationCapability,
) -> Self {
@@ -170,7 +170,7 @@ impl AsyncManagedClient {
&server_name,
server.clone(),
store_mode,
runtime_environment,
runtime_context,
runtime_auth_provider,
)
.await?,
@@ -560,29 +560,17 @@ async fn make_rmcp_client(
server_name: &str,
server: EffectiveMcpServer,
store_mode: OAuthCredentialsStoreMode,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
) -> Result<RmcpClient, StartupOutcomeError> {
let config = match server.launch() {
McpServerLaunch::Configured(config) => config.as_ref().clone(),
};
if let Some(reason) = runtime_environment.startup_unavailable_reason(server_name, &config) {
return Err(StartupOutcomeError::from(anyhow!(reason)));
}
let McpServerConfig {
transport,
experimental_environment,
..
} = config;
let remote_environment = match experimental_environment.as_deref() {
None | Some("local") => false,
Some("remote") => true,
Some(environment) => {
return Err(StartupOutcomeError::from(anyhow!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)));
}
};
let resolved_environment = runtime_context
.resolve_server_environment(server_name, &config)
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))?;
let is_local_environment = config.is_local_environment();
let McpServerConfig { transport, .. } = config;
match transport {
McpServerTransportConfig::Stdio {
@@ -599,27 +587,24 @@ async fn make_rmcp_client(
.map(|(key, value)| (key.into(), value.into()))
.collect::<HashMap<_, _>>()
});
let launcher = if remote_environment {
// Preflight should reject this first, but keep client startup
// defensive if optional runtime placement is mis-threaded.
let Some(environment) = runtime_environment.environment() else {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server requires a runtime environment"
)));
let launcher = if is_local_environment {
// TODO(starr): Unify local stdio MCP launch with
// `ExecutorStdioServerLauncher` once the executor-backed path
// preserves `LocalStdioServerLauncher` semantics.
Arc::new(LocalStdioServerLauncher::new(
runtime_context.local_stdio_fallback_cwd(),
)) as Arc<dyn StdioServerLauncher>
} else {
let Some(environment) = resolved_environment.as_ref() else {
unreachable!(
"non-local stdio MCP servers resolve an environment before launch"
);
};
Arc::new(ExecutorStdioServerLauncher::new(
environment.get_exec_backend(),
runtime_environment.fallback_cwd(),
))
} else {
Arc::new(LocalStdioServerLauncher::new(
runtime_environment.fallback_cwd(),
)) as Arc<dyn StdioServerLauncher>
};
// `RmcpClient` always sees a launched MCP stdio server. The
// launcher hides whether that means a local child process or an
// executor process whose stdin/stdout bytes cross the process API.
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
.await
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
@@ -630,16 +615,10 @@ async fn make_rmcp_client(
env_http_headers,
bearer_token_env_var,
} => {
let http_client: Arc<dyn HttpClient> = if remote_environment {
let Some(environment) = runtime_environment.environment() else {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server requires a runtime environment"
)));
};
environment.get_http_client()
} else {
Arc::new(ReqwestHttpClient)
};
let http_client = resolved_environment.as_ref().map_or_else(
|| Arc::new(ReqwestHttpClient) as Arc<dyn HttpClient>,
|environment| environment.get_http_client(),
);
let resolved_bearer_token =
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
Ok(token) => token,
+164 -133
View File
@@ -10,6 +10,7 @@ use std::sync::Arc;
use std::time::Duration;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SandboxPolicy;
@@ -28,83 +29,87 @@ pub struct SandboxState {
pub use_legacy_landlock: bool,
}
/// Runtime placement information used when starting MCP server transports.
/// Runtime context used when resolving per-server MCP environments.
///
/// `McpConfig` describes what servers exist. This value describes which
/// selected/default environment MCP servers should use for the current caller.
/// Keep it explicit at manager construction time so status/snapshot paths and
/// real sessions make the same placement decision. `fallback_cwd` is not a
/// per-server override; it is used when a stdio server omits `cwd` and the
/// launcher needs a concrete process working directory. `local_environment`
/// is separate because a remote selected/default environment can coexist with
/// an explicitly configured local environment that may launch local stdio MCPs.
/// `McpConfig` describes what servers exist. This value carries the canonical
/// environment registry plus the local stdio fallback cwd used when a local
/// stdio server omits its own working directory.
#[derive(Clone)]
pub struct McpRuntimeEnvironment {
environment: Option<Arc<Environment>>,
local_environment: Option<Arc<Environment>>,
fallback_cwd: PathBuf,
pub struct McpRuntimeContext {
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
}
impl McpRuntimeEnvironment {
impl McpRuntimeContext {
pub fn new(
environment: Option<Arc<Environment>>,
local_environment: Option<Arc<Environment>>,
fallback_cwd: PathBuf,
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
) -> Self {
Self {
environment,
local_environment,
fallback_cwd,
environment_manager,
local_stdio_fallback_cwd,
}
}
pub(crate) fn environment(&self) -> Option<Arc<Environment>> {
self.environment.as_ref().map(Arc::clone)
pub(crate) fn local_stdio_fallback_cwd(&self) -> PathBuf {
self.local_stdio_fallback_cwd.clone()
}
pub(crate) fn fallback_cwd(&self) -> PathBuf {
self.fallback_cwd.clone()
}
pub(crate) fn startup_unavailable_reason(
pub(crate) fn resolve_server_environment(
&self,
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Option<String> {
// This is intentionally narrower than "no env means no MCP": local
// stdio needs a local process launcher, while local HTTP can still use
// the ambient HTTP client with no local environment configured.
match config.experimental_environment.as_deref() {
None | Some("local") => {
// Local stdio only needs an explicitly configured local
// launcher. The selected/default MCP environment can be remote
// when both local and remote environments are configured.
if self.local_environment.is_none()
&& matches!(
config.transport,
codex_config::McpServerTransportConfig::Stdio { .. }
)
{
Some(format!(
"local stdio MCP server `{server_name}` requires a local environment"
))
} else {
None
}
) -> Result<Option<Arc<Environment>>, String> {
// Resolve `"local"` through the shared registry when available. Local
// HTTP is the one current exception: it can use the ambient HTTP client
// even when no local Environment is configured.
if let Some(environment) = self
.environment_manager
.get_environment(&config.environment_id)
{
if !config.is_local_environment() {
ensure_remote_stdio_cwd(server_name, config)?;
}
Some("remote") => match self.environment.as_ref() {
Some(environment) if environment.is_remote() => None,
_ => Some(format!(
"remote MCP server `{server_name}` requires a remote environment"
)),
},
Some(environment) => Some(format!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)),
return Ok(Some(environment));
}
if config.is_local_environment() {
return match config.transport {
codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!(
"local stdio MCP server `{server_name}` requires a local environment"
)),
codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None),
};
}
Err(format!(
"MCP server `{server_name}` references unknown environment id `{}`",
config.environment_id
))
}
}
fn ensure_remote_stdio_cwd(
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Result<(), String> {
let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`",
cwd.display()
))
}
pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(metric, duration, tags);
@@ -115,13 +120,15 @@ pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &st
mod tests {
use std::collections::HashMap;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_exec_server::EnvironmentManager;
use pretty_assertions::assert_eq;
use super::*;
fn stdio_server(experimental_environment: Option<&str>) -> McpServerConfig {
fn stdio_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
@@ -130,7 +137,7 @@ mod tests {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: experimental_environment.map(str::to_string),
environment_id: environment_id.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -147,7 +154,7 @@ mod tests {
}
}
fn http_server(experimental_environment: Option<&str>) -> McpServerConfig {
fn http_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "http://127.0.0.1:1".to_string(),
@@ -155,111 +162,135 @@ mod tests {
http_headers: None,
env_http_headers: None,
},
experimental_environment: experimental_environment.map(str::to_string),
..stdio_server(/*experimental_environment*/ None)
environment_id: environment_id.to_string(),
..stdio_server(environment_id)
}
}
#[test]
fn local_stdio_requires_local_stdio_availability() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(_) => panic!("local stdio MCP should require a local environment"),
Err(error) => error,
};
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ None)
),
Some("local stdio MCP server `stdio` requires a local environment".to_string())
error,
"local stdio MCP server `stdio` requires a local environment"
);
}
#[test]
fn local_http_does_not_require_local_stdio_availability() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"http",
&http_server(/*experimental_environment*/ None)
),
None
);
let resolved_runtime = match runtime_context
.resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local HTTP MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_none());
}
#[test]
fn remote_stdio_requires_remote_environment() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
fn unknown_explicit_environment_is_rejected() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error =
match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) {
Ok(_) => panic!("unknown MCP environment should fail"),
Err(error) => error,
};
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ Some("remote")),
),
Some("remote MCP server `stdio` requires a remote environment".to_string())
);
}
#[test]
fn remote_stdio_and_http_accept_remote_environment() {
let environment = Arc::new(
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
);
let runtime_environment = McpRuntimeEnvironment::new(
Some(environment),
/*local_environment*/ None,
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ Some("remote")),
),
None
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"http",
&http_server(/*experimental_environment*/ Some("remote")),
),
None
error,
"MCP server `stdio` references unknown environment id `remote`"
);
}
#[tokio::test]
async fn local_stdio_accepts_remote_runtime_when_local_environment_exists() {
let remote_environment = Arc::new(
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
);
let local_environment = Arc::new(
Environment::create_for_tests(/*exec_server_url*/ None).expect("local environment"),
);
let runtime_environment = McpRuntimeEnvironment::new(
Some(remote_environment),
Some(local_environment),
async fn explicit_remote_stdio_and_http_accept_named_environment() {
let runtime_context = McpRuntimeContext::new(
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
/*local_runtime_paths*/ None,
)
.await,
),
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ None),
let mut remote_stdio = stdio_server("remote");
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(std::env::temp_dir());
for resolved_runtime in [
runtime_context.resolve_server_environment("stdio", &remote_stdio),
runtime_context.resolve_server_environment("http", &http_server("remote")),
] {
let resolved_runtime = match resolved_runtime {
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("remote MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
}
#[tokio::test]
async fn local_stdio_accepts_local_environment_when_available() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::default_for_tests()),
PathBuf::from("/tmp"),
);
let resolved_runtime = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local stdio MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
#[tokio::test]
async fn remote_stdio_requires_absolute_cwd() {
let runtime_context = McpRuntimeContext::new(
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
/*local_runtime_paths*/ None,
)
.await,
),
None
PathBuf::from("/tmp"),
);
let mut remote_stdio = stdio_server("remote");
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(PathBuf::from("relative"));
let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) {
Ok(_) => panic!("remote stdio MCP should require absolute cwd"),
Err(error) => error,
};
assert_eq!(
error,
"remote stdio MCP server `stdio` requires an absolute cwd, got `relative`"
);
}
}
+2 -1
View File
@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true }
tonic = { workspace = true }
tonic-prost = { workspace = true }
+122 -1
View File
@@ -19,6 +19,7 @@ use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -432,14 +433,58 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilesystemRequirementsToml {
pub deny_read: Option<Vec<FilesystemDenyReadPattern>>,
}
#[derive(Deserialize)]
struct RawFilesystemRequirementsToml {
deny_read: Option<Vec<FilesystemDenyReadPattern>>,
description: Option<serde::de::IgnoredAny>,
extends: Option<serde::de::IgnoredAny>,
workspace_roots: Option<serde::de::IgnoredAny>,
filesystem: Option<serde::de::IgnoredAny>,
network: Option<serde::de::IgnoredAny>,
}
impl<'de> Deserialize<'de> for FilesystemRequirementsToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?;
let RawFilesystemRequirementsToml {
deny_read,
description,
extends,
workspace_roots,
filesystem,
network,
} = raw;
if description.is_some()
|| extends.is_some()
|| workspace_roots.is_some()
|| filesystem.is_some()
|| network.is_some()
{
return Err(D::Error::custom(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile",
));
}
Ok(Self { deny_read })
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PermissionsRequirementsToml {
pub filesystem: Option<FilesystemRequirementsToml>,
// For legacy reasons, `filesystem` stays reserved for requirements-level
// filesystem constraints and cannot name a profile.
#[serde(default, flatten)]
pub profiles: BTreeMap<String, PermissionProfileToml>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -701,6 +746,7 @@ pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_permissions: Option<Vec<String>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
@@ -752,6 +798,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_permissions: Option<Sourced<Vec<String>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
@@ -789,6 +836,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: _,
allowed_approvals_reviewers: _,
allowed_sandbox_modes: _,
allowed_permissions: _,
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
@@ -821,6 +869,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -850,6 +899,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -868,6 +918,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
allowed_permissions: allowed_permissions.map(|sourced| sourced.value),
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
@@ -953,6 +1004,7 @@ impl ConfigRequirementsToml {
self.allowed_approval_policies.is_none()
&& self.allowed_approvals_reviewers.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.allowed_permissions.is_none()
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
@@ -992,10 +1044,14 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
type Error = ConstraintError;
fn try_from(toml: ConfigRequirementsWithSources) -> Result<Self, Self::Error> {
// Profile catalog selection remains on ConfigRequirementsToml for
// config loading and requirements API projection. The normalized
// constraints below only need the compiled PermissionProfile envelope.
let ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions: _,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -1301,6 +1357,7 @@ mod tests {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
@@ -1323,6 +1380,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_sandbox_modes: allowed_sandbox_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_permissions: allowed_permissions
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
@@ -1370,6 +1429,61 @@ mod tests {
Ok(())
}
#[test]
fn deserialize_managed_permission_profiles() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allowed_permissions = ["managed-standard", "managed-build"]
[permissions.managed-standard]
extends = ":workspace"
[permissions.managed-build]
extends = "managed-standard"
"#,
)?;
assert_eq!(
requirements.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
let permissions = requirements
.permissions
.as_ref()
.expect("managed permission profiles");
assert!(permissions.profiles.contains_key("managed-standard"));
assert!(
permissions
.profiles
.get("managed-build")
.and_then(|profile| profile.extends.as_deref())
.is_some()
);
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn filesystem_requirements_table_cannot_define_a_permission_profile() {
let err = from_str::<ConfigRequirementsToml>(
r#"
[permissions.filesystem]
extends = ":workspace"
"#,
)
.expect_err("filesystem requirements cannot define a permission profile");
assert!(
err.to_string().contains(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile"
),
"unexpected error: {err:#}"
);
}
#[test]
fn deserialize_computer_use_requirements() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
@@ -1421,6 +1535,7 @@ mod tests {
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
allowed_permissions: Some(vec!["managed".to_string()]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
@@ -1451,6 +1566,10 @@ mod tests {
source.clone(),
)),
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)),
allowed_permissions: Some(Sourced::new(
vec!["managed".to_string()],
source.clone(),
)),
allowed_web_search_modes: Some(Sourced::new(
allowed_web_search_modes,
enforce_source.clone(),
@@ -1501,6 +1620,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -1550,6 +1670,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
+1 -1
View File
@@ -359,7 +359,7 @@ pub struct ConfigToml {
pub personality: Option<Personality>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
+1
View File
@@ -94,6 +94,7 @@ pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::ConfigEditsBuilder;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_types::AppToolApproval;
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
pub use mcp_types::McpServerConfig;
pub use mcp_types::McpServerDisabledReason;
pub use mcp_types::McpServerEnvVar;
+1 -1
View File
@@ -235,7 +235,7 @@ pub async fn load_config_layers_state(
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"--profile-v2 `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`",
"--profile `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`. See https://developers.openai.com/codex/config-advanced#profiles for more information.",
base_user_file.as_path().display(),
active_user_file.as_path().display()
),
+5 -1
View File
@@ -120,7 +120,7 @@ model = "gpt-work"
);
let message = err.to_string();
assert!(
message.contains("--profile-v2 `work` cannot be used"),
message.contains("--profile `work` cannot be used"),
"unexpected error message: {message}"
);
assert!(
@@ -131,6 +131,10 @@ model = "gpt-work"
message.contains("[profiles.work]"),
"unexpected error message: {message}"
);
assert!(
message.contains("https://developers.openai.com/codex/config-advanced#profiles"),
"unexpected error message: {message}"
);
}
#[tokio::test]
+2 -2
View File
@@ -175,8 +175,8 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
if !config.enabled {
entry["enabled"] = value(false);
}
if let Some(environment) = &config.experimental_environment {
entry["experimental_environment"] = value(environment.clone());
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
+2 -2
View File
@@ -23,7 +23,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: true,
@@ -101,7 +101,7 @@ async fn replace_mcp_servers_serializes_oauth_client_id() -> anyhow::Result<()>
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+40 -6
View File
@@ -13,6 +13,9 @@ use serde::de::Error as SerdeError;
use crate::RequirementSource;
/// Effective MCP environment id when config omits `environment_id`.
pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppToolApproval {
@@ -128,9 +131,8 @@ pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
/// Experimental environment selector for where Codex should start this MCP server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub experimental_environment: Option<String>,
/// Effective environment id for where Codex should start this MCP server.
pub environment_id: String,
/// When `false`, Codex skips initializing this MCP server.
#[serde(default = "default_enabled")]
@@ -190,6 +192,10 @@ pub struct McpServerConfig {
}
impl McpServerConfig {
pub fn is_local_environment(&self) -> bool {
self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID
}
pub fn oauth_client_id(&self) -> Option<&str> {
self.oauth
.as_ref()
@@ -231,7 +237,7 @@ pub struct RawMcpServerConfig {
// shared
#[serde(default)]
pub experimental_environment: Option<String>,
pub environment_id: Option<String>,
#[serde(default)]
pub startup_timeout_sec: Option<f64>,
#[serde(default)]
@@ -279,7 +285,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
url,
bearer_token,
bearer_token_env_var,
experimental_environment,
environment_id,
startup_timeout_sec,
startup_timeout_ms,
tool_timeout_sec,
@@ -350,9 +356,13 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
return Err("invalid transport".to_string());
};
let environment_id =
environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
validate_remote_stdio_cwd(&transport, &environment_id)?;
Ok(Self {
transport,
experimental_environment,
environment_id,
startup_timeout_sec,
tool_timeout_sec,
enabled: enabled.unwrap_or_else(default_enabled),
@@ -385,6 +395,30 @@ const fn default_enabled() -> bool {
true
}
fn validate_remote_stdio_cwd(
transport: &McpServerTransportConfig,
environment_id: &str,
) -> Result<(), String> {
if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
return Ok(());
}
let McpServerTransportConfig::Stdio { cwd, .. } = transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`",
cwd.display()
))
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
pub enum McpServerTransportConfig {
+57 -1
View File
@@ -51,6 +51,62 @@ fn deserialize_stdio_command_server_config_with_args() {
assert!(cfg.enabled);
}
#[test]
fn deserialize_remote_stdio_server_requires_absolute_cwd() {
let missing_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
"#,
)
.expect_err("remote stdio MCP should require cwd");
assert!(
missing_cwd
.to_string()
.contains("remote stdio MCP servers require an absolute cwd"),
"unexpected error: {missing_cwd}"
);
let relative_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
cwd = "relative"
"#,
)
.expect_err("remote stdio MCP should require absolute cwd");
assert!(
relative_cwd.to_string().contains("got `relative`"),
"unexpected error: {relative_cwd}"
);
}
#[test]
fn deserialize_remote_stdio_server_accepts_absolute_cwd() {
let cwd = std::env::temp_dir();
let cfg: McpServerConfig = match toml::from_str(&format!(
r#"
command = "echo"
environment_id = "remote"
cwd = {cwd:?}
"#
)) {
Ok(cfg) => cfg,
Err(error) => panic!("remote stdio MCP should accept absolute cwd: {error}"),
};
assert_eq!(
cfg.transport,
McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec![],
env: None,
env_vars: Vec::new(),
cwd: Some(cwd),
}
);
}
#[test]
fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() {
let cfg: McpServerConfig = toml::from_str(
@@ -402,7 +458,7 @@ fn deserialize_ignores_unknown_server_fields() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+206
View File
@@ -1,12 +1,18 @@
use std::collections::BTreeMap;
use crate::merge::merge_toml_values;
use codex_network_proxy::InjectedHeaderConfig;
use codex_network_proxy::MitmHookActionsConfig;
use codex_network_proxy::MitmHookBodyConfig;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -350,6 +356,38 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<NetworkMitmToml>,
}
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub query: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
pub body: Option<MitmHookBodyConfig>,
pub action: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -359,6 +397,114 @@ enum NetworkModeSchema {
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmInjectedHeaderToml {
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
pub prefix: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
struct MitmHookBodyConfigSchema(pub serde_json::Value);
impl<'de> Deserialize<'de> for NetworkMitmToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
let mitm = Self {
hooks: unchecked.hooks,
actions: unchecked.actions,
};
mitm.validate_action_definitions()
.map_err(serde::de::Error::custom)?;
Ok(mitm)
}
}
impl NetworkMitmToml {
pub fn validate_action_definitions(&self) -> Result<(), String> {
if let Some(actions) = self.actions.as_ref() {
for (action_name, action) in actions {
if action.is_empty() {
return Err(format!(
"network.mitm.actions.{action_name} must define at least one operation"
));
}
}
}
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
if hook.action.is_empty() {
return Err(format!(
"network.mitm.hooks.{hook_name}.action must not be empty"
));
}
}
Ok(())
}
pub fn validate_action_references(
&self,
actions_by_name: &IndexMap<String, NetworkMitmActionToml>,
) -> Result<(), String> {
self.validate_action_definitions()?;
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
for action_name in &hook.action {
if !actions_by_name.contains_key(action_name) {
return Err(format!(
"network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"
));
}
}
}
Ok(())
}
pub fn to_runtime_hooks(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> Vec<MitmHookConfig> {
self.hooks
.as_ref()
.map(|hooks| {
hooks
.values()
.map(|hook| hook.to_runtime(actions_by_name))
.collect()
})
.unwrap_or_default()
}
}
impl NetworkMitmActionToml {
pub fn is_empty(&self) -> bool {
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
}
}
impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
@@ -411,6 +557,11 @@ impl NetworkToml {
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
}
if let Some(mitm) = self.mitm.as_ref() {
config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
}
config.network.mitm =
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
}
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
@@ -420,6 +571,61 @@ impl NetworkToml {
}
}
impl NetworkMitmHookToml {
fn to_runtime(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookConfig {
MitmHookConfig {
host: self.host.clone(),
matcher: MitmHookMatchConfig {
methods: self.methods.clone(),
path_prefixes: self.path_prefixes.clone(),
query: self.query.clone(),
headers: self.headers.clone(),
body: self.body.clone(),
},
actions: self.selected_actions(actions_by_name),
}
}
fn selected_actions(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookActionsConfig {
let Some(actions_by_name) = actions_by_name else {
return MitmHookActionsConfig::default();
};
let mut selected = MitmHookActionsConfig::default();
for action_name in &self.action {
if let Some(action) = actions_by_name.get(action_name) {
selected
.strip_request_headers
.extend(action.strip_request_headers.clone());
selected.inject_request_headers.extend(
action
.inject_request_headers
.iter()
.map(NetworkMitmInjectedHeaderToml::to_runtime),
);
}
}
selected
}
}
impl NetworkMitmInjectedHeaderToml {
fn to_runtime(&self) -> InjectedHeaderConfig {
InjectedHeaderConfig {
name: self.name.clone(),
secret_env_var: self.secret_env_var.clone(),
secret_file: self.secret_file.clone(),
prefix: self.prefix.clone(),
}
}
}
pub fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
+1 -1
View File
@@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval;
pub struct ConfigProfile {
pub model: Option<String>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
+8 -13
View File
@@ -112,7 +112,6 @@ pub async fn load_plugins_from_layer_stack(
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
restriction_product: Option<Product>,
plugin_hooks_enabled: bool,
) -> PluginLoadOutcome<McpServerConfig> {
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
let mut configured_plugins = configured_plugins_from_stack(config_layer_stack);
@@ -129,7 +128,6 @@ pub async fn load_plugins_from_layer_stack(
store,
restriction_product,
&skill_config_rules,
plugin_hooks_enabled,
)
.await;
for name in loaded_plugin.mcp_servers.keys() {
@@ -499,7 +497,6 @@ async fn load_plugin(
store: &PluginStore,
restriction_product: Option<Product>,
skill_config_rules: &SkillConfigRules,
plugin_hooks_enabled: bool,
) -> LoadedPlugin<McpServerConfig> {
let plugin_id = PluginId::parse(&config_name);
let active_plugin_root = plugin_id
@@ -597,16 +594,14 @@ async fn load_plugin(
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
if plugin_hooks_enabled {
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&plugin_root,
&loaded_plugin_id,
&store.plugin_data_root(&loaded_plugin_id),
manifest_paths,
);
loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
}
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&plugin_root,
&loaded_plugin_id,
&store.plugin_data_root(&loaded_plugin_id),
manifest_paths,
);
loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
loaded_plugin
}
+29 -42
View File
@@ -22,6 +22,7 @@ use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplaceError;
use crate::marketplace::MarketplaceInterface;
use crate::marketplace::MarketplaceListError;
use crate::marketplace::MarketplaceListOutcome;
use crate::marketplace::MarketplacePluginAuthPolicy;
use crate::marketplace::MarketplacePluginPolicy;
use crate::marketplace::MarketplacePluginSource;
@@ -89,7 +90,6 @@ 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,
}
@@ -98,14 +98,12 @@ impl PluginsConfigInput {
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,
}
}
@@ -414,7 +412,6 @@ pub struct PluginsManager {
#[derive(Clone)]
struct CachedPluginLoadOutcome {
config_version: String,
plugin_hooks_enabled: bool,
outcome: PluginLoadOutcome,
}
@@ -485,12 +482,8 @@ impl PluginsManager {
return PluginLoadOutcome::default();
}
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) =
self.cached_enabled_outcome(&config_version, plugin_hooks_enabled)
{
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) {
return outcome;
}
@@ -499,7 +492,6 @@ impl PluginsManager {
self.remote_installed_plugin_configs(),
&self.store,
self.restriction_product,
plugin_hooks_enabled,
)
.await;
log_plugin_load_errors(&outcome);
@@ -509,7 +501,6 @@ impl PluginsManager {
};
*cache = Some(CachedPluginLoadOutcome {
config_version,
plugin_hooks_enabled,
outcome: outcome.clone(),
});
outcome
@@ -537,7 +528,6 @@ impl PluginsManager {
&self,
config_layer_stack: &ConfigLayerStack,
config: &PluginsConfigInput,
plugin_hooks_feature_enabled: bool,
) -> PluginLoadOutcome {
if !config.plugins_enabled {
return PluginLoadOutcome::default();
@@ -547,7 +537,6 @@ impl PluginsManager {
self.remote_installed_plugin_configs(),
&self.store,
self.restriction_product,
plugin_hooks_feature_enabled,
)
.await
}
@@ -558,31 +547,21 @@ impl PluginsManager {
config_layer_stack: &ConfigLayerStack,
config: &PluginsConfigInput,
) -> Vec<PluginSkillRoot> {
self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled)
self.plugins_for_layer_stack(config_layer_stack, config)
.await
.effective_plugin_skill_roots()
}
fn cached_enabled_outcome(
&self,
config_version: &str,
plugin_hooks_enabled: bool,
) -> Option<PluginLoadOutcome> {
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache
.as_ref()
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.filter(|cached| cached.config_version == config_version)
.map(|cached| cached.outcome.clone()),
Err(err) => err
.into_inner()
.as_ref()
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
})
.filter(|cached| cached.config_version == config_version)
.map(|cached| cached.outcome.clone()),
}
}
@@ -1208,7 +1187,7 @@ impl PluginsManager {
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let marketplace_outcome =
list_marketplaces(&self.marketplace_roots(config, additional_roots))?;
self.discover_marketplaces_for_config(config, additional_roots)?;
let mut seen_plugin_keys = HashSet::new();
let marketplaces = marketplace_outcome
.marketplaces
@@ -1286,6 +1265,18 @@ impl PluginsManager {
})
}
pub fn discover_marketplaces_for_config(
&self,
config: &PluginsConfigInput,
additional_roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
if !config.plugins_enabled {
return Ok(MarketplaceListOutcome::default());
}
list_marketplaces(&self.marketplace_roots(config, additional_roots))
}
pub async fn read_plugin_for_config(
&self,
config: &PluginsConfigInput,
@@ -1438,20 +1429,16 @@ impl PluginsManager {
),
)
.await;
let hooks = if config.plugin_hooks_enabled {
let plugin_data_root = self.store.plugin_data_root(&plugin_id);
let (hook_sources, _hook_load_warnings) =
load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths);
plugin_hook_declarations(&hook_sources)
.into_iter()
.map(|hook| PluginHookSummary {
key: hook.key,
event_name: hook.event_name,
})
.collect()
} else {
Vec::new()
};
let plugin_data_root = self.store.plugin_data_root(&plugin_id);
let (hook_sources, _hook_load_warnings) =
load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths);
let hooks = plugin_hook_declarations(&hook_sources)
.into_iter()
.map(|hook| PluginHookSummary {
key: hook.key,
event_name: hook.event_name,
})
.collect();
let apps = load_plugin_apps(source_path.as_path()).await;
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path())
.await
+4 -77
View File
@@ -101,18 +101,6 @@ fn run_git(repo: &Path, args: &[&str]) {
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
plugin_config_toml_with_plugin_hooks(
enabled,
plugins_feature_enabled,
/*plugin_hooks_feature_enabled*/ false,
)
}
fn plugin_config_toml_with_plugin_hooks(
enabled: bool,
plugins_feature_enabled: bool,
plugin_hooks_feature_enabled: bool,
) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
@@ -120,10 +108,6 @@ fn plugin_config_toml_with_plugin_hooks(
"plugins".to_string(),
Value::Boolean(plugins_feature_enabled),
);
features.insert(
"plugin_hooks".to_string(),
Value::Boolean(plugin_hooks_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
@@ -235,7 +219,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -738,7 +722,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -850,7 +834,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1012,7 +996,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1133,61 +1117,6 @@ async fn load_plugins_returns_empty_when_feature_disabled() {
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"PreToolUse": [
{
"hooks": [{ "type": "command", "command": "echo plugin hook" }]
}
]
}
}"#,
);
let manager = PluginsManager::new(codex_home.path().to_path_buf());
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ false,
),
);
let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let without_plugin_hooks = manager
.plugins_for_config(&config_without_plugin_hooks)
.await;
assert!(
without_plugin_hooks
.effective_plugin_hook_sources()
.is_empty()
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml_with_plugin_hooks(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
/*plugin_hooks_feature_enabled*/ true,
),
);
let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await;
assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1);
}
#[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() {
let codex_home = TempDir::new().unwrap();
@@ -2032,7 +1961,6 @@ async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cl
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
plugin_hooks = true
[plugins."toolkit@debug"]
enabled = true
@@ -3741,7 +3669,6 @@ async fn load_plugins_ignores_project_config_files() {
std::collections::HashMap::new(),
&PluginStore::new(codex_home.path().to_path_buf()),
Some(Product::Codex),
/*plugin_hooks_enabled*/ false,
)
.await;
+2 -1
View File
@@ -698,7 +698,8 @@ pub fn plugin_interface_with_marketplace_category(
interface
}
fn marketplace_root_dir(
#[doc(hidden)]
pub fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {
@@ -120,11 +120,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin
"remote_plugin",
/*default_enabled*/ false,
),
feature_enabled(
&effective_config,
"plugin_hooks",
/*default_enabled*/ false,
),
"https://chatgpt.com/backend-api/".to_string(),
)
}
+119 -4
View File
@@ -686,7 +686,7 @@
"$ref": "#/definitions/SandboxMode"
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"tools": {
@@ -1584,6 +1584,118 @@
"NetworkDomainPermissionsToml": {
"type": "object"
},
"NetworkMitmActionToml": {
"properties": {
"inject_request_headers": {
"default": [],
"items": {
"$ref": "#/definitions/NetworkMitmInjectedHeaderToml"
},
"type": "array"
},
"strip_request_headers": {
"default": [],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"NetworkMitmHookToml": {
"additionalProperties": false,
"properties": {
"action": {
"items": {
"type": "string"
},
"type": "array"
},
"body": true,
"headers": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
},
"host": {
"type": "string"
},
"methods": {
"items": {
"type": "string"
},
"type": "array"
},
"path_prefixes": {
"items": {
"type": "string"
},
"type": "array"
},
"query": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"default": {},
"type": "object"
}
},
"required": [
"action",
"host",
"methods",
"path_prefixes"
],
"type": "object"
},
"NetworkMitmInjectedHeaderToml": {
"properties": {
"name": {
"default": "",
"type": "string"
},
"prefix": {
"default": null,
"type": "string"
},
"secret_env_var": {
"default": null,
"type": "string"
},
"secret_file": {
"default": null,
"type": "string"
}
},
"type": "object"
},
"NetworkMitmToml": {
"additionalProperties": false,
"properties": {
"actions": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmActionToml"
},
"type": "object"
},
"hooks": {
"additionalProperties": {
"$ref": "#/definitions/NetworkMitmHookToml"
},
"type": "object"
}
},
"type": "object"
},
"NetworkModeSchema": {
"enum": [
"limited",
@@ -1687,6 +1799,9 @@
"enabled": {
"type": "boolean"
},
"mitm": {
"$ref": "#/definitions/NetworkMitmToml"
},
"mode": {
"$ref": "#/definitions/NetworkModeSchema"
},
@@ -2189,7 +2304,7 @@
},
"type": "array"
},
"experimental_environment": {
"environment_id": {
"default": null,
"type": "string"
},
@@ -4783,7 +4898,7 @@
"description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
},
"service_tier": {
"description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).",
"description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).",
"type": "string"
},
"shell_environment_policy": {
@@ -4883,4 +4998,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+30 -14
View File
@@ -260,6 +260,7 @@ struct WebsocketSession {
connection: Option<ApiWebSocketConnection>,
last_request: Option<ResponsesApiRequest>,
last_response_rx: Option<oneshot::Receiver<LastResponse>>,
last_response_from_untraced_warmup: bool,
connection_reused: StdMutex<bool>,
}
@@ -748,8 +749,7 @@ impl ModelClient {
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.thread_id.to_string());
let service_tier =
service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier));
let service_tier = model_info.service_tier_for_request(service_tier);
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
@@ -942,6 +942,7 @@ impl ModelClientSession {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
self.websocket_session
.set_connection_reused(/*connection_reused*/ false);
}
@@ -1045,28 +1046,33 @@ impl ModelClientSession {
&mut self,
payload: ResponseCreateWsRequest,
request: &ResponsesApiRequest,
) -> ResponsesWsRequest {
) -> (ResponsesWsRequest, bool) {
let Some(last_response) = self.get_last_response() else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
let previous_response_id_from_untraced_warmup =
self.websocket_session.last_response_from_untraced_warmup;
let Some(incremental_items) = self.get_incremental_items(
request,
Some(&last_response),
/*allow_empty_delta*/ true,
) else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
if last_response.response_id.is_empty() {
trace!("incremental request failed, no previous response id");
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
}
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
})
(
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
}),
previous_response_id_from_untraced_warmup,
)
}
/// Opportunistically preconnects a websocket for this turn-scoped client session.
@@ -1145,6 +1151,7 @@ impl ModelClientSession {
if needs_new {
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
let turn_state = options
.turn_state
.clone()
@@ -1413,8 +1420,8 @@ impl ModelClientSession {
Err(err) => return Err(map_api_error(err)),
}
let mut ws_request = self.prepare_websocket_request(ws_payload, &request);
self.websocket_session.last_request = Some(request);
let (mut ws_request, previous_response_id_from_untraced_warmup) =
self.prepare_websocket_request(ws_payload, &request);
let inference_trace_attempt = if warmup {
// Prewarm sends `generate=false`; it is connection setup, not a
// model inference attempt that should appear in rollout traces.
@@ -1423,7 +1430,16 @@ impl ModelClientSession {
inference_trace.start_attempt()
};
stamp_ws_stream_request_start_ms(&mut ws_request);
inference_trace_attempt.record_started(&ws_request);
if previous_response_id_from_untraced_warmup {
// The transport can reuse an untraced warmup response id and omit the
// already-sent input, but rollout replay needs the logical model-visible
// request rather than the compressed websocket delta.
inference_trace_attempt.record_started(&request);
} else {
inference_trace_attempt.record_started(&ws_request);
}
self.websocket_session.last_request = Some(request);
self.websocket_session.last_response_from_untraced_warmup = warmup;
let websocket_connection =
self.websocket_session.connection.as_ref().ok_or_else(|| {
map_api_error(ApiError::Stream(
+1 -1
View File
@@ -286,7 +286,7 @@ pub(crate) async fn process_compacted_history(
/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary
/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this
/// check.
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => {
+240 -6
View File
@@ -10,6 +10,7 @@ use crate::compact::compaction_status_from_result;
use crate::compact_remote::build_compact_request_log_data;
use crate::compact_remote::log_remote_compact_failure;
use crate::compact_remote::process_compacted_history;
use crate::compact_remote::should_keep_compacted_history_item;
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
use crate::hook_runtime::PostCompactHookOutcome;
use crate::hook_runtime::PreCompactHookOutcome;
@@ -27,17 +28,25 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout_trace::CompactionCheckpointTracePayload;
use codex_rollout_trace::InferenceTraceContext;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::truncate_text;
use futures::StreamExt;
use futures::TryFutureExt;
use tokio_util::sync::CancellationToken;
use tracing::info;
// Mirror the current /responses/compact retained-message default while the
// server-side path remains the reference implementation.
const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
@@ -343,11 +352,14 @@ fn build_v2_compacted_history(
prompt_input: &[ResponseItem],
compaction_output: ResponseItem,
) -> Vec<ResponseItem> {
let mut retained = prompt_input
let retained = prompt_input
.iter()
.filter(|item| is_retained_for_remote_compaction_v2(item))
.filter(|item| should_keep_compacted_history_item(item))
.cloned()
.collect::<Vec<_>>();
let mut retained =
truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET);
retained.push(compaction_output);
retained
}
@@ -360,6 +372,98 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
matches!(role.as_str(), "user" | "developer" | "system")
}
fn truncate_retained_messages_for_remote_compaction(
items: Vec<ResponseItem>,
max_tokens: usize,
) -> Vec<ResponseItem> {
let mut remaining = max_tokens;
let mut truncated_reversed = Vec::with_capacity(items.len());
for item in items.into_iter().rev() {
if remaining == 0 {
continue;
}
let token_count = message_text_token_count(&item).max(1);
if token_count <= remaining {
truncated_reversed.push(item);
remaining = remaining.saturating_sub(token_count);
} else if let Some(truncated_item) =
truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining)
{
truncated_reversed.push(truncated_item);
remaining = 0;
}
}
truncated_reversed.reverse();
truncated_reversed
}
fn message_text_token_count(item: &ResponseItem) -> usize {
let ResponseItem::Message { content, .. } = item else {
return 0;
};
content
.iter()
.map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
approx_token_count(text)
}
ContentItem::InputImage { .. } => 0,
})
.sum()
}
fn truncate_message_text_to_token_budget(
item: ResponseItem,
max_tokens: usize,
) -> Option<ResponseItem> {
let ResponseItem::Message {
id,
role,
content,
phase,
} = item
else {
return Some(item);
};
let mut remaining = max_tokens;
let mut truncated_content = Vec::with_capacity(content.len());
for mut content_item in content {
match &mut content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
if remaining == 0 {
continue;
}
let token_count = approx_token_count(text);
if token_count <= remaining {
remaining = remaining.saturating_sub(token_count);
} else {
*text = truncate_text(text, TruncationPolicy::Tokens(remaining));
remaining = 0;
}
if !text.is_empty() {
truncated_content.push(content_item);
}
}
ContentItem::InputImage { .. } => truncated_content.push(content_item),
}
}
if truncated_content.is_empty() {
return None;
}
Some(ResponseItem::Message {
id,
role,
content: truncated_content,
phase,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -395,7 +499,7 @@ mod tests {
}
#[test]
fn build_v2_compacted_history_matches_prod_retention_shape() {
fn build_v2_compacted_history_filters_to_installed_retention_shape() {
let input = vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
@@ -421,15 +525,145 @@ mod tests {
assert_eq!(
history,
vec![message("user", "user", /*phase*/ None), output]
);
}
#[test]
fn build_v2_compacted_history_discards_messages_before_truncating() {
let old = message("user", "old", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let huge_developer_message = "d".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4);
let huge_contextual_message = format!(
"<environment_context>\n{}\n</environment_context>",
"c".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4)
);
let input = vec![
old.clone(),
message("developer", &huge_developer_message, /*phase*/ None),
message("user", &huge_contextual_message, /*phase*/ None),
new.clone(),
];
let output = ResponseItem::Compaction {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
assert_eq!(history, vec![old, new, output]);
}
#[test]
fn retained_history_truncation_keeps_newest_messages_first() {
let middle = message("user", "middle1234", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old-old", /*phase*/ None),
middle,
new.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
message("user", "user", /*phase*/ None),
output,
message("user", "midd…1 tokens truncated…1234", /*phase*/ None),
new,
]
);
}
#[test]
fn retained_history_truncation_preserves_images_and_truncates_later_text_parts() {
let item = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uvwxyz".to_string(),
},
],
phase: None,
};
let truncated =
truncate_retained_messages_for_remote_compaction(vec![item], /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uv…1 tokens truncated…yz".to_string(),
},
],
phase: None,
}]
);
}
#[test]
fn retained_history_truncation_charges_image_only_messages() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old", /*phase*/ None),
image_only_message.clone(),
newest.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 2);
assert_eq!(truncated, vec![image_only_message, newest]);
}
#[test]
fn retained_history_truncation_drops_image_only_messages_after_budget_is_spent() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![image_only_message, newest.clone()];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 1);
assert_eq!(truncated, vec![newest]);
}
#[tokio::test]
async fn collect_compaction_output_accepts_additional_output_items() {
let compaction = ResponseItem::Compaction {

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