From d7de4dd3ac3e4c489368c3985013e78622496962 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 5 May 2026 16:46:51 -0700 Subject: [PATCH 01/12] chore(app-server-protocol): split v2 API definitions into modules (#21251) ## Why `codex-rs/app-server-protocol/src/protocol/v2.rs` had grown into a single ~12k-line definition file for the entire app-server v2 API. This is purely a mechanical refactor to break up the monolithic `v2.rs` file that contains all app-server API v2 types into more modular files, grouped by resource (e.g. account, thread, turn, etc.). `just write-app-server-schema` shows no real changes, so we can be sure that this is purely an internal organizational change. ## What changed - Replaced the monolithic `protocol/v2.rs` with a `protocol/v2/` module tree and a small `mod.rs` that only declares and reexports modules. - Grouped v2 API definitions by conceptual owner, including `account`, `apps`, `collaboration_mode`, `command_exec`, `config`, `device_key`, `experimental_feature`, `feedback`, `fs`, `hook`, `item`, `mcp`, `model`, `notification`, `permissions`, `plugin`, `process`, `realtime`, `review`, `thread`, `thread_data`, `turn`, and `windows_sandbox`. - Moved v2 tests into `protocol/v2/tests.rs` so `mod.rs` stays small. - Kept shared protocol helpers in `protocol/v2/shared.rs`, including the enum mirroring macro and common cross-resource types. - Co-located resource-specific notifications and server-request payloads with the modules that own those resources. - Regenerated app-server protocol schema fixtures. The schema diffs are non-semantic newline-only changes after the refactor. ## Verification - `cargo check -p codex-app-server-protocol` - `cargo test -p codex-app-server-protocol` - `just write-app-server-schema` --- .../schema/json/ServerNotification.json | 2 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../json/v2/ItemCompletedNotification.json | 2 +- .../json/v2/ItemStartedNotification.json | 2 +- .../app-server-protocol/src/protocol/v2.rs | 11953 ---------------- .../src/protocol/v2/account.rs | 383 + .../src/protocol/v2/apps.rs | 146 + .../src/protocol/v2/collaboration_mode.rs | 45 + .../src/protocol/v2/command_exec.rs | 214 + .../src/protocol/v2/config.rs | 703 + .../src/protocol/v2/device_key.rs | 181 + .../src/protocol/v2/experimental_feature.rs | 85 + .../src/protocol/v2/feedback.rs | 29 + .../app-server-protocol/src/protocol/v2/fs.rs | 204 + .../src/protocol/v2/hook.rs | 154 + .../src/protocol/v2/item.rs | 1432 ++ .../src/protocol/v2/mcp.rs | 703 + .../src/protocol/v2/mod.rs | 53 + .../src/protocol/v2/model.rs | 151 + .../src/protocol/v2/notification.rs | 56 + .../src/protocol/v2/permissions.rs | 854 ++ .../src/protocol/v2/plugin.rs | 649 + .../src/protocol/v2/process.rs | 204 + .../src/protocol/v2/realtime.rs | 241 + .../src/protocol/v2/review.rs | 65 + .../src/protocol/v2/shared.rs | 316 + .../src/protocol/v2/tests.rs | 3566 +++++ .../src/protocol/v2/thread.rs | 1146 ++ .../src/protocol/v2/thread_data.rs | 162 + .../src/protocol/v2/turn.rs | 390 + .../src/protocol/v2/windows_sandbox.rs | 63 + 32 files changed, 12200 insertions(+), 11958 deletions(-) delete mode 100644 codex-rs/app-server-protocol/src/protocol/v2.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/account.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/apps.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/config.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/device_key.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/feedback.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/fs.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/hook.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/item.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/mcp.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/mod.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/model.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/notification.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/permissions.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/plugin.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/process.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/realtime.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/review.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/shared.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/tests.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/thread.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/turn.rs create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 046bab211c..ae54b610f5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6074,4 +6074,4 @@ } ], "title": "ServerNotification" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index dafa256aec..8e0e2c39b4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -18553,4 +18553,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index f028f9b3ea..67aaf9e389 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -16438,4 +16438,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 4ec0e10bc9..6909415c2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1393,4 +1393,4 @@ ], "title": "ItemCompletedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 8684989356..758ceba32d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1393,4 +1393,4 @@ ], "title": "ItemStartedNotification", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs deleted file mode 100644 index 3328cd2516..0000000000 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ /dev/null @@ -1,11953 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::num::NonZeroUsize; -use std::path::PathBuf; - -use crate::RequestId; -use crate::protocol::common::AuthMode; -use crate::protocol::item_builders::convert_patch_changes; -use codex_experimental_api_macros::ExperimentalApi; -use codex_protocol::account::PlanType; -use codex_protocol::account::ProviderAccount; -use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; -use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; -use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; -use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; -use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; -use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; -use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; -use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; -use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; -use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode as CoreSandboxMode; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WebSearchToolConfig; -use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; -use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; -use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; -use codex_protocol::items::TurnItem as CoreTurnItem; -use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; -use codex_protocol::mcp::Resource as McpResource; -pub use codex_protocol::mcp::ResourceContent as McpResourceContent; -use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; -use codex_protocol::mcp::Tool as McpTool; -use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; -use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; -use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; -use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; -use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::default_input_modalities; -use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; -use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; -use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; -use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::AskForApproval as CoreAskForApproval; -use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; -use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; -use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; -use codex_protocol::protocol::HookEventName as CoreHookEventName; -use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; -use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; -use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; -use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; -use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; -use codex_protocol::protocol::HookScope as CoreHookScope; -use codex_protocol::protocol::HookSource as CoreHookSource; -use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; -use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; -use codex_protocol::protocol::ModelVerification as CoreModelVerification; -use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; -use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; -use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationVersion; -use codex_protocol::protocol::RealtimeOutputModality; -use codex_protocol::protocol::RealtimeVoice; -use codex_protocol::protocol::RealtimeVoicesList; -use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; -use codex_protocol::protocol::SessionSource as CoreSessionSource; -use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; -use codex_protocol::protocol::SkillInterface as CoreSkillInterface; -use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; -use codex_protocol::protocol::SkillScope as CoreSkillScope; -use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; -use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; -use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; -use codex_protocol::protocol::TokenUsage as CoreTokenUsage; -use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; -use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; -use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; -use codex_protocol::user_input::ByteRange as CoreByteRange; -use codex_protocol::user_input::TextElement as CoreTextElement; -use codex_protocol::user_input::UserInput as CoreUserInput; -use codex_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use schemars::r#gen::SchemaGenerator; -use schemars::schema::InstanceType; -use schemars::schema::Metadata; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use serde_with::serde_as; -use thiserror::Error; -use ts_rs::TS; - -// Macro to declare a camelCased API v2 enum mirroring a core enum which -// tends to use either snake_case or kebab-case. -macro_rules! v2_enum_from_core { - ( - $(#[$enum_meta:meta])* - pub enum $Name:ident from $Src:path { - $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? - } - ) => { - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] - $(#[$enum_meta])* - #[serde(rename_all = "camelCase")] - #[ts(export_to = "v2/")] - pub enum $Name { - $( $(#[$variant_meta])* $Variant ),+ - } - - impl $Name { - pub fn to_core(self) -> $Src { - match self { $( $Name::$Variant => <$Src>::$Variant ),+ } - } - } - - impl From<$Src> for $Name { - fn from(value: $Src) -> Self { - match value { $( <$Src>::$Variant => $Name::$Variant ),+ } - } - } - }; -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NonSteerableTurnKind { - Review, - Compact, -} - -/// This translation layer make sure that we expose codex error code in camel case. -/// -/// When an upstream HTTP status is available (for example, from the Responses API or a provider), -/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CodexErrorInfo { - ContextWindowExceeded, - UsageLimitExceeded, - ServerOverloaded, - CyberPolicy, - HttpConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Failed to connect to the response SSE stream. - ResponseStreamConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - InternalServerError, - Unauthorized, - BadRequest, - ThreadRollbackFailed, - SandboxError, - /// The response SSE stream disconnected in the middle of a turn before completion. - ResponseStreamDisconnected { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Reached the retry limit for responses. - ResponseTooManyFailedAttempts { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn - /// cannot accept same-turn steering, for example `/review` or manual `/compact`. - ActiveTurnNotSteerable { - #[serde(rename = "turnKind")] - #[ts(rename = "turnKind")] - turn_kind: NonSteerableTurnKind, - }, - Other, -} - -impl From for CodexErrorInfo { - fn from(value: CoreCodexErrorInfo) -> Self { - match value { - CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, - CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, - CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, - CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, - CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { - CodexErrorInfo::HttpConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { - CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, - CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, - CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, - CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, - CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, - CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { - CodexErrorInfo::ResponseStreamDisconnected { http_status_code } - } - CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { - CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } - } - CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { - CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - } - } - CoreCodexErrorInfo::Other => CodexErrorInfo::Other, - } - } -} - -impl From for NonSteerableTurnKind { - fn from(value: CoreNonSteerableTurnKind) -> Self { - match value { - CoreNonSteerableTurnKind::Review => Self::Review, - CoreNonSteerableTurnKind::Compact => Self::Compact, - } - } -} - -#[derive( - Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum AskForApproval { - #[serde(rename = "untrusted")] - #[ts(rename = "untrusted")] - UnlessTrusted, - OnFailure, - OnRequest, - #[experimental("askForApproval.granular")] - Granular { - sandbox_approval: bool, - rules: bool, - #[serde(default)] - skill_approval: bool, - #[serde(default)] - request_permissions: bool, - mcp_elicitations: bool, - }, - Never, -} - -impl AskForApproval { - pub fn to_core(self) -> CoreAskForApproval { - match self { - AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => CoreAskForApproval::OnFailure, - AskForApproval::OnRequest => CoreAskForApproval::OnRequest, - AskForApproval::Granular { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - }), - AskForApproval::Never => CoreAskForApproval::Never, - } - } -} - -impl From for AskForApproval { - fn from(value: CoreAskForApproval) -> Self { - match value { - CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, - CoreAskForApproval::OnFailure => AskForApproval::OnFailure, - CoreAskForApproval::OnRequest => AskForApproval::OnRequest, - CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { - sandbox_approval: granular_config.sandbox_approval, - rules: granular_config.rules, - skill_approval: granular_config.skill_approval, - request_permissions: granular_config.request_permissions, - mcp_elicitations: granular_config.mcp_elicitations, - }, - CoreAskForApproval::Never => AskForApproval::Never, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] -#[ts( - type = r#""user" | "auto_review" | "guardian_subagent""#, - export_to = "v2/" -)] -/// Configures who approval requests are routed to for review. Examples -/// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully -/// prompted subagent to gather relevant context and apply a risk-based -/// decision framework before approving or denying the request. -pub enum ApprovalsReviewer { - #[serde(rename = "user")] - User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] - AutoReview, -} - -impl JsonSchema for ApprovalsReviewer { - fn schema_name() -> String { - "ApprovalsReviewer".to_string() - } - - fn json_schema(_generator: &mut SchemaGenerator) -> Schema { - string_enum_schema_with_description( - &["user", "auto_review", "guardian_subagent"], - "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", - ) - } -} - -fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { - let mut schema = SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some(description.to_string()), - ..Default::default() - })), - ..Default::default() - }; - schema.enum_values = Some( - values - .iter() - .map(|value| JsonValue::String((*value).to_string())) - .collect(), - ); - Schema::Object(schema) -} - -impl ApprovalsReviewer { - pub fn to_core(self) -> CoreApprovalsReviewer { - match self { - ApprovalsReviewer::User => CoreApprovalsReviewer::User, - ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, - } - } -} - -impl From for ApprovalsReviewer { - fn from(value: CoreApprovalsReviewer) -> Self { - match value { - CoreApprovalsReviewer::User => ApprovalsReviewer::User, - CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum SandboxMode { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -impl SandboxMode { - pub fn to_core(self) -> CoreSandboxMode { - match self { - SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, - SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, - SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, - } - } -} - -impl From for SandboxMode { - fn from(value: CoreSandboxMode) -> Self { - match value { - CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, - CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, - CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, - } - } -} - -v2_enum_from_core!( - pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { - Inline, Detached - } -); - -v2_enum_from_core!( - pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { - Unsupported, - NotLoggedIn, - BearerToken, - OAuth - } -); - -v2_enum_from_core!( - pub enum ModelRerouteReason from CoreModelRerouteReason { - HighRiskCyberActivity - } -); - -v2_enum_from_core!( - pub enum ModelVerification from CoreModelVerification { - TrustedAccessForCyber - } -); - -v2_enum_from_core!( - pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop - } -); - -v2_enum_from_core!( - pub enum HookHandlerType from CoreHookHandlerType { - Command, Prompt, Agent - } -); - -v2_enum_from_core!( - pub enum HookExecutionMode from CoreHookExecutionMode { - Sync, Async - } -); - -v2_enum_from_core!( - pub enum HookScope from CoreHookScope { - Thread, Turn - } -); - -v2_enum_from_core!( - pub enum HookSource from CoreHookSource { - System, - User, - Project, - Mdm, - SessionFlags, - Plugin, - CloudRequirements, - LegacyManagedConfigFile, - LegacyManagedConfigMdm, - Unknown, - } -); - -v2_enum_from_core!( - pub enum HookTrustStatus from CoreHookTrustStatus { - Managed, Untrusted, Trusted, Modified - } -); - -fn default_hook_source() -> HookSource { - HookSource::Unknown -} - -v2_enum_from_core!( - pub enum HookRunStatus from CoreHookRunStatus { - Running, Completed, Failed, Blocked, Stopped - } -); - -v2_enum_from_core!( - pub enum HookOutputEntryKind from CoreHookOutputEntryKind { - Warning, Stop, Feedback, Context, Error - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadStartSource { - Startup, - Clear, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookOutputEntry { - pub kind: HookOutputEntryKind, - pub text: String, -} - -impl From for HookOutputEntry { - fn from(value: CoreHookOutputEntry) -> Self { - Self { - kind: value.kind.into(), - text: value.text, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookRunSummary { - pub id: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub execution_mode: HookExecutionMode, - pub scope: HookScope, - pub source_path: AbsolutePathBuf, - #[serde(default = "default_hook_source")] - pub source: HookSource, - pub display_order: i64, - pub status: HookRunStatus, - pub status_message: Option, - pub started_at: i64, - pub completed_at: Option, - pub duration_ms: Option, - pub entries: Vec, -} - -impl From for HookRunSummary { - fn from(value: CoreHookRunSummary) -> Self { - Self { - id: value.id, - event_name: value.event_name.into(), - handler_type: value.handler_type.into(), - execution_mode: value.execution_mode.into(), - scope: value.scope.into(), - source_path: value.source_path, - source: value.source.into(), - display_order: value.display_order, - status: value.status.into(), - status_message: value.status_message, - started_at: value.started_at, - completed_at: value.completed_at, - duration_ms: value.duration_ms, - entries: value.entries.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ConfigLayerSource { - /// Managed preferences layer delivered by MDM (macOS only). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Mdm { - domain: String, - key: String, - }, - - /// Managed config layer from a file (usually `managed_config.toml`). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - System { - /// This is the path to the system config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// User config layer from $CODEX_HOME/config.toml. This layer is special - /// in that it is expected to be: - /// - writable by the user - /// - generally outside the workspace directory - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - User { - /// This is the path to the user's config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// Path to a .codex/ folder within a project. There could be multiple of - /// these between `cwd` and the project/repo root. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Project { - dot_codex_folder: AbsolutePathBuf, - }, - - /// Session-layer overrides supplied via `-c`/`--config`. - SessionFlags, - - /// `managed_config.toml` was designed to be a config that was loaded - /// as the last layer on top of everything else. This scheme did not quite - /// work out as intended, but we keep this variant as a "best effort" while - /// we phase out `managed_config.toml` in favor of `requirements.toml`. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - LegacyManagedConfigTomlFromFile { - file: AbsolutePathBuf, - }, - - LegacyManagedConfigTomlFromMdm, -} - -impl ConfigLayerSource { - /// A settings from a layer with a higher precedence will override a setting - /// from a layer with a lower precedence. - pub fn precedence(&self) -> i16 { - match self { - ConfigLayerSource::Mdm { .. } => 0, - ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, - ConfigLayerSource::Project { .. } => 25, - ConfigLayerSource::SessionFlags => 30, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, - ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, - } - } -} - -/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from -/// layer `A` will be overridden by settings from layer `B`. -impl PartialOrd for ConfigLayerSource { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.precedence().cmp(&other.precedence())) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct SandboxWorkspaceWrite { - #[serde(default)] - pub writable_roots: Vec, - #[serde(default)] - pub network_access: bool, - #[serde(default)] - pub exclude_tmpdir_env_var: bool, - #[serde(default)] - pub exclude_slash_tmp: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ToolsV2 { - pub web_search: Option, - pub view_image: Option, -} - -#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolSpec { - #[ts(optional)] - pub namespace: Option, - pub name: String, - pub description: String, - pub input_schema: JsonValue, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub defer_loading: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DynamicToolSpecDe { - namespace: Option, - name: String, - description: String, - input_schema: JsonValue, - defer_loading: Option, - expose_to_context: Option, -} - -impl<'de> Deserialize<'de> for DynamicToolSpec { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let DynamicToolSpecDe { - namespace, - name, - description, - input_schema, - defer_loading, - expose_to_context, - } = DynamicToolSpecDe::deserialize(deserializer)?; - - Ok(Self { - namespace, - name, - description, - input_schema, - defer_loading: defer_loading - .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), - }) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AnalyticsConfig { - pub enabled: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum AppToolApproval { - Auto, - Prompt, - Approve, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsDefaultConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - #[serde(default = "default_enabled")] - pub destructive_enabled: bool, - #[serde(default = "default_enabled")] - pub open_world_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolConfig { - pub enabled: Option, - pub approval_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolsConfig { - #[serde(default, flatten)] - pub tools: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - pub destructive_enabled: Option, - pub open_world_enabled: Option, - pub default_tools_approval_mode: Option, - pub default_tools_enabled: Option, - pub tools: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsConfig { - #[serde(default, rename = "_default")] - pub default: Option, - #[serde(default, flatten)] - pub apps: HashMap, -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct Config { - pub model: Option, - pub review_model: Option, - pub model_context_window: Option, - pub model_auto_compact_token_limit: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional default for where approval requests are routed for - /// review. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub sandbox_mode: Option, - pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, - pub forced_login_method: Option, - pub web_search: Option, - pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, - pub instructions: Option, - pub developer_instructions: Option, - pub compact_prompt: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub service_tier: Option, - pub analytics: Option, - #[experimental("config/read.apps")] - #[serde(default)] - pub apps: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayerMetadata { - pub name: ConfigLayerSource, - pub version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayer { - pub name: ConfigLayerSource, - pub version: String, - pub config: JsonValue, - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum MergeStrategy { - Replace, - Upsert, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WriteStatus { - Ok, - OkOverridden, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct OverriddenMetadata { - pub message: String, - pub overriding_layer: ConfigLayerMetadata, - pub effective_value: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWriteResponse { - pub status: WriteStatus, - pub version: String, - /// Canonical path to the config file that was written. - pub file_path: AbsolutePathBuf, - pub overridden_metadata: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ConfigWriteErrorCode { - ConfigLayerReadonly, - ConfigVersionConflict, - ConfigValidationError, - ConfigPathNotFound, - ConfigSchemaUnknownKey, - UserLayerNotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadParams { - #[serde(default)] - pub include_layers: bool, - /// Optional working directory to resolve project config layers. If specified, - /// return the effective config as seen from that directory (i.e., including any - /// project layers between `cwd` and the project/repo root). - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadResponse { - #[experimental(nested)] - pub config: Config, - pub origins: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub layers: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirements { - #[experimental(nested)] - pub allowed_approval_policies: Option>, - #[experimental("configRequirements/read.allowedApprovalsReviewers")] - pub allowed_approvals_reviewers: Option>, - pub allowed_sandbox_modes: Option>, - pub allowed_web_search_modes: Option>, - pub feature_requirements: Option>, - #[experimental("configRequirements/read.hooks")] - pub hooks: Option, - pub enforce_residency: Option, - #[experimental("configRequirements/read.network")] - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ManagedHooksRequirements { - pub managed_dir: Option, - pub windows_managed_dir: Option, - #[serde(rename = "PreToolUse")] - #[ts(rename = "PreToolUse")] - pub pre_tool_use: Vec, - #[serde(rename = "PermissionRequest")] - #[ts(rename = "PermissionRequest")] - pub permission_request: Vec, - #[serde(rename = "PostToolUse")] - #[ts(rename = "PostToolUse")] - pub post_tool_use: Vec, - #[serde(rename = "SessionStart")] - #[ts(rename = "SessionStart")] - pub session_start: Vec, - #[serde(rename = "UserPromptSubmit")] - #[ts(rename = "UserPromptSubmit")] - pub user_prompt_submit: Vec, - #[serde(rename = "Stop")] - #[ts(rename = "Stop")] - pub stop: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfiguredHookMatcherGroup { - pub matcher: Option, - pub hooks: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ConfiguredHookHandler { - #[serde(rename = "command")] - #[ts(rename = "command")] - Command { - command: String, - #[serde(rename = "timeoutSec")] - #[ts(rename = "timeoutSec")] - timeout_sec: Option, - r#async: bool, - #[serde(rename = "statusMessage")] - #[ts(rename = "statusMessage")] - status_message: Option, - }, - #[serde(rename = "prompt")] - #[ts(rename = "prompt")] - Prompt {}, - #[serde(rename = "agent")] - #[ts(rename = "agent")] - Agent {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkRequirements { - pub enabled: Option, - pub http_port: Option, - pub socks_port: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_all_unix_sockets: Option, - /// Canonical network permission map for `experimental_network`. - pub domains: Option>, - /// When true, only managed allowlist entries are respected while managed - /// network enforcement is active. - pub managed_allowed_domains_only: Option, - /// Legacy compatibility view derived from `domains`. - pub allowed_domains: Option>, - /// Legacy compatibility view derived from `domains`. - pub denied_domains: Option>, - /// Canonical unix socket permission map for `experimental_network`. - pub unix_sockets: Option>, - /// Legacy compatibility view derived from `unix_sockets`. - pub allow_unix_sockets: Option>, - pub allow_local_binding: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkDomainPermission { - Allow, - Deny, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkUnixSocketPermission { - Allow, - None, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ResidencyRequirement { - Us, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirementsReadResponse { - /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). - #[experimental(nested)] - pub requirements: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum ExternalAgentConfigMigrationItemType { - #[serde(rename = "AGENTS_MD")] - #[ts(rename = "AGENTS_MD")] - AgentsMd, - #[serde(rename = "CONFIG")] - #[ts(rename = "CONFIG")] - Config, - #[serde(rename = "SKILLS")] - #[ts(rename = "SKILLS")] - Skills, - #[serde(rename = "PLUGINS")] - #[ts(rename = "PLUGINS")] - Plugins, - #[serde(rename = "MCP_SERVER_CONFIG")] - #[ts(rename = "MCP_SERVER_CONFIG")] - McpServerConfig, - #[serde(rename = "SUBAGENTS")] - #[ts(rename = "SUBAGENTS")] - Subagents, - #[serde(rename = "HOOKS")] - #[ts(rename = "HOOKS")] - Hooks, - #[serde(rename = "COMMANDS")] - #[ts(rename = "COMMANDS")] - Commands, - #[serde(rename = "SESSIONS")] - #[ts(rename = "SESSIONS")] - Sessions, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginsMigration { - #[serde(rename = "marketplaceName")] - #[ts(rename = "marketplaceName")] - pub marketplace_name: String, - #[serde(rename = "pluginNames")] - #[ts(rename = "pluginNames")] - pub plugin_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SessionMigration { - pub path: PathBuf, - pub cwd: PathBuf, - pub title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SubagentMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MigrationDetails { - #[serde(default)] - pub plugins: Vec, - #[serde(default)] - pub sessions: Vec, - #[serde(default)] - pub mcp_servers: Vec, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub subagents: Vec, - #[serde(default)] - pub commands: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigMigrationItem { - pub item_type: ExternalAgentConfigMigrationItemType, - pub description: String, - /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. - pub cwd: Option, - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectResponse { - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_home: bool, - /// Zero or more working directories to include for repo-scoped detection. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportParams { - pub migration_items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportCompletedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigValueWriteParams { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigBatchWriteParams { - pub edits: Vec, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, - /// When true, hot-reload the updated user config into all loaded threads after writing. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub reload_user_config: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigEdit { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionApprovalDecision { - /// User approved the command. - Accept, - /// User approved the command and future prompts in the same session-scoped - /// approval cache should run without prompting. - AcceptForSession, - /// User approved the command, and wants to apply the proposed execpolicy amendment so future - /// matching commands can run without prompting. - AcceptWithExecpolicyAmendment { - execpolicy_amendment: ExecPolicyAmendment, - }, - /// User chose a persistent network policy rule (allow/deny) for this host. - ApplyNetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - /// User denied the command. The agent will continue the turn. - Decline, - /// User denied the command. The turn will also be immediately interrupted. - Cancel, -} - -impl From for CommandExecutionApprovalDecision { - fn from(value: CoreReviewDecision) -> Self { - match value { - CoreReviewDecision::Approved => Self::Accept, - CoreReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => Self::AcceptWithExecpolicyAmendment { - execpolicy_amendment: proposed_execpolicy_amendment.into(), - }, - CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, - CoreReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => Self::ApplyNetworkPolicyAmendment { - network_policy_amendment: network_policy_amendment.into(), - }, - CoreReviewDecision::Abort => Self::Cancel, - CoreReviewDecision::Denied => Self::Decline, - CoreReviewDecision::TimedOut => Self::Decline, - } - } -} - -v2_enum_from_core! { - pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { - Http, - Https, - Socks5Tcp, - Socks5Udp, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkApprovalContext { - pub host: String, - pub protocol: NetworkApprovalProtocol, -} - -impl From for NetworkApprovalContext { - fn from(value: CoreNetworkApprovalContext) -> Self { - Self { - host: value.host, - protocol: value.protocol.into(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalFileSystemPermissions { - /// This will be removed in favor of `entries`. - pub read: Option>, - /// This will be removed in favor of `entries`. - pub write: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub glob_scan_max_depth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub entries: Option>, -} - -impl From for AdditionalFileSystemPermissions { - fn from(value: CoreFileSystemPermissions) -> Self { - if let Some((read, write)) = value.legacy_read_write_roots() { - let mut entries = Vec::with_capacity( - read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), - ); - if let Some(paths) = read.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Read, - })); - } - if let Some(paths) = write.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Write, - })); - } - Self { - read, - write, - glob_scan_max_depth: None, - entries: Some(entries), - } - } else { - Self { - read: None, - write: None, - glob_scan_max_depth: value.glob_scan_max_depth, - entries: Some( - value - .entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - ), - } - } - } -} - -impl From for CoreFileSystemPermissions { - fn from(value: AdditionalFileSystemPermissions) -> Self { - let mut permissions = if let Some(entries) = value.entries { - Self { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth: None, - } - } else { - CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) - }; - permissions.glob_scan_max_depth = value.glob_scan_max_depth; - permissions - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalNetworkPermissions { - pub enabled: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - -impl From for AdditionalNetworkPermissions { - fn from(value: CoreNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for CoreNetworkPermissions { - fn from(value: AdditionalNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct RequestPermissionProfile { - pub network: Option, - pub file_system: Option, -} - -impl From for RequestPermissionProfile { - fn from(value: CoreRequestPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreRequestPermissionProfile { - fn from(value: RequestPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -v2_enum_from_core!( - pub enum FileSystemAccessMode from CoreFileSystemAccessMode { - Read, - Write, - None - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(tag = "kind")] -#[ts(export_to = "v2/")] -pub enum FileSystemSpecialPath { - Root, - Minimal, - #[serde(alias = "current_working_directory")] - ProjectRoots { - subpath: Option, - }, - Tmpdir, - SlashTmp, - Unknown { - path: String, - subpath: Option, - }, -} - -impl From for FileSystemSpecialPath { - fn from(value: CoreFileSystemSpecialPath) -> Self { - match value { - CoreFileSystemSpecialPath::Root => Self::Root, - CoreFileSystemSpecialPath::Minimal => Self::Minimal, - CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, - CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, - CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -impl From for CoreFileSystemSpecialPath { - fn from(value: FileSystemSpecialPath) -> Self { - match value { - FileSystemSpecialPath::Root => Self::Root, - FileSystemSpecialPath::Minimal => Self::Minimal, - FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - FileSystemSpecialPath::Tmpdir => Self::Tmpdir, - FileSystemSpecialPath::SlashTmp => Self::SlashTmp, - FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum FileSystemPath { - Path { path: AbsolutePathBuf }, - GlobPattern { pattern: String }, - Special { value: FileSystemSpecialPath }, -} - -impl From for FileSystemPath { - fn from(value: CoreFileSystemPath) -> Self { - match value { - CoreFileSystemPath::Path { path } => Self::Path { path }, - CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - CoreFileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -impl From for CoreFileSystemPath { - fn from(value: FileSystemPath) -> Self { - match value { - FileSystemPath::Path { path } => Self::Path { path }, - FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - FileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileSystemSandboxEntry { - pub path: FileSystemPath, - pub access: FileSystemAccessMode, -} - -impl From for FileSystemSandboxEntry { - fn from(value: CoreFileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.into(), - } - } -} - -impl From for CoreFileSystemSandboxEntry { - fn from(value: FileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.to_core(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, -} - -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ActivePermissionProfile { - /// Identifier from `default_permissions` or the implicit built-in default, - /// such as `:workspace` or a user-defined `[permissions.]` profile. - pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. - #[serde(default)] - pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for ActivePermissionProfile { - fn from(value: CoreActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), - } - } -} - -impl From for CoreActivePermissionProfile { - fn from(value: ActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalPermissionProfile { - /// Partial overlay used for per-command permission requests. - pub network: Option, - pub file_system: Option, -} - -impl From for AdditionalPermissionProfile { - fn from(value: CoreAdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: AdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GrantedPermissionProfile { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub file_system: Option, -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: GrantedPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum FileChangeApprovalDecision { - /// User approved the file changes. - Accept, - /// User approved the file changes and future changes to the same files should run without prompting. - AcceptForSession, - /// User denied the file changes. The agent will continue the turn. - Decline, - /// User denied the file changes. The turn will also be immediately interrupted. - Cancel, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NetworkAccess { - #[default] - Restricted, - Enabled, -} - -#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum SandboxPolicy { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum SandboxPolicyDeserialize { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - #[serde(default)] - access: Option, - }, - #[serde(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - read_only_access: Option, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum LegacyReadOnlyAccess { - FullAccess, - Restricted, -} - -impl<'de> Deserialize<'de> for SandboxPolicy { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match SandboxPolicyDeserialize::deserialize(deserializer)? { - SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), - SandboxPolicyDeserialize::ReadOnly { - network_access, - access, - } => { - if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "readOnly.access is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::ReadOnly { network_access }) - } - SandboxPolicyDeserialize::ExternalSandbox { network_access } => { - Ok(SandboxPolicy::ExternalSandbox { network_access }) - } - SandboxPolicyDeserialize::WorkspaceWrite { - writable_roots, - read_only_access, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => { - if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }) - } - } - } -} - -impl SandboxPolicy { - pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { - match self { - SandboxPolicy::DangerFullAccess => { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess - } - SandboxPolicy::ReadOnly { network_access } => { - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: *network_access, - } - } - SandboxPolicy::ExternalSandbox { network_access } => { - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: match network_access { - NetworkAccess::Restricted => CoreNetworkAccess::Restricted, - NetworkAccess::Enabled => CoreNetworkAccess::Enabled, - }, - } - } - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - } - } -} - -impl From for SandboxPolicy { - fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { - match value { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { - SandboxPolicy::DangerFullAccess - } - codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { - SandboxPolicy::ReadOnly { network_access } - } - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { - SandboxPolicy::ExternalSandbox { - network_access: match network_access { - CoreNetworkAccess::Restricted => NetworkAccess::Restricted, - CoreNetworkAccess::Enabled => NetworkAccess::Enabled, - }, - } - } - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(transparent)] -#[ts(type = "Array", export_to = "v2/")] -pub struct ExecPolicyAmendment { - pub command: Vec, -} - -impl ExecPolicyAmendment { - pub fn into_core(self) -> CoreExecPolicyAmendment { - CoreExecPolicyAmendment::new(self.command) - } -} - -impl From for ExecPolicyAmendment { - fn from(value: CoreExecPolicyAmendment) -> Self { - Self { - command: value.command().to_vec(), - } - } -} - -v2_enum_from_core!( - pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { - Allow, Deny - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkPolicyAmendment { - pub host: String, - pub action: NetworkPolicyRuleAction, -} - -impl NetworkPolicyAmendment { - pub fn into_core(self) -> CoreNetworkPolicyAmendment { - CoreNetworkPolicyAmendment { - host: self.host, - action: self.action.to_core(), - } - } -} - -impl From for NetworkPolicyAmendment { - fn from(value: CoreNetworkPolicyAmendment) -> Self { - Self { - host: value.host, - action: NetworkPolicyRuleAction::from(value.action), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum CommandAction { - Read { - command: String, - name: String, - path: AbsolutePathBuf, - }, - ListFiles { - command: String, - path: Option, - }, - Search { - command: String, - query: Option, - path: Option, - }, - Unknown { - command: String, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -#[derive(Default)] -pub enum SessionSource { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - #[default] - VsCode, - Exec, - AppServer, - Custom(String), - SubAgent(CoreSubAgentSource), - #[serde(other)] - Unknown, -} - -impl From for SessionSource { - fn from(value: CoreSessionSource) -> Self { - match value { - CoreSessionSource::Cli => SessionSource::Cli, - CoreSessionSource::VSCode => SessionSource::VsCode, - CoreSessionSource::Exec => SessionSource::Exec, - CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::Custom(source) => SessionSource::Custom(source), - // We do not want to render those at the app-server level. - CoreSessionSource::Internal(_) => SessionSource::Unknown, - CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), - CoreSessionSource::Unknown => SessionSource::Unknown, - } - } -} - -impl From for CoreSessionSource { - fn from(value: SessionSource) -> Self { - match value { - SessionSource::Cli => CoreSessionSource::Cli, - SessionSource::VsCode => CoreSessionSource::VSCode, - SessionSource::Exec => CoreSessionSource::Exec, - SessionSource::AppServer => CoreSessionSource::Mcp, - SessionSource::Custom(source) => CoreSessionSource::Custom(source), - SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), - SessionSource::Unknown => CoreSessionSource::Unknown, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GitInfo { - pub sha: Option, - pub branch: Option, - pub origin_url: Option, -} - -impl CommandAction { - pub fn into_core(self) -> CoreParsedCommand { - match self { - CommandAction::Read { - command: cmd, - name, - path, - } => CoreParsedCommand::Read { - cmd, - name, - path: path.into_path_buf(), - }, - CommandAction::ListFiles { command: cmd, path } => { - CoreParsedCommand::ListFiles { cmd, path } - } - CommandAction::Search { - command: cmd, - query, - path, - } => CoreParsedCommand::Search { cmd, query, path }, - CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, - } - } -} - -impl CommandAction { - pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { - match value { - CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { - command: cmd, - name, - path: cwd.join(path), - }, - CoreParsedCommand::ListFiles { cmd, path } => { - CommandAction::ListFiles { command: cmd, path } - } - CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { - command: cmd, - query, - path, - }, - CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum Account { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, - - #[serde(rename = "amazonBedrock", rename_all = "camelCase")] - #[ts(rename = "amazonBedrock", rename_all = "camelCase")] - AmazonBedrock {}, -} - -impl From for Account { - fn from(account: ProviderAccount) -> Self { - match account { - ProviderAccount::ApiKey => Self::ApiKey {}, - ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, - ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(tag = "type")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountParams { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { - #[serde(rename = "apiKey")] - #[ts(rename = "apiKey")] - api_key: String, - }, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - codex_streamlined_login: bool, - }, - #[serde(rename = "chatgptDeviceCode")] - #[ts(rename = "chatgptDeviceCode")] - ChatgptDeviceCode, - /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. - /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. - #[experimental("account/login/start.chatgptAuthTokens")] - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens { - /// Access token (JWT) supplied by the client. - /// This token is used for backend API requests and email extraction. - access_token: String, - /// Workspace/account identifier supplied by the client. - chatgpt_account_id: String, - /// Optional plan type supplied by the client. - /// - /// When `null`, Codex attempts to derive the plan type from access-token - /// claims. If unavailable, the plan defaults to `unknown`. - #[ts(optional = nullable)] - chatgpt_plan_type: Option, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountResponse { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to initiate the OAuth flow. - auth_url: String, - }, - #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] - #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] - ChatgptDeviceCode { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to complete device code authorization. - verification_url: String, - /// One-time code the user must enter after signing in. - user_code: String, - }, - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountParams { - pub login_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CancelLoginAccountStatus { - Canceled, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountResponse { - pub status: CancelLoginAccountStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct LogoutAccountResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ChatgptAuthTokensRefreshReason { - /// Codex attempted a backend request and received `401 Unauthorized`. - Unauthorized, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshParams { - pub reason: ChatgptAuthTokensRefreshReason, - /// Workspace/account identifier that Codex was previously using. - /// - /// Clients that manage multiple accounts/workspaces can use this as a hint - /// to refresh the token for the correct workspace. - /// - /// This may be `null` when the prior auth state did not include a workspace - /// identifier (`chatgpt_account_id`). - #[ts(optional = nullable)] - pub previous_account_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshResponse { - pub access_token: String, - pub chatgpt_account_id: String, - pub chatgpt_plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountRateLimitsResponse { - /// Backward-compatible single-bucket view; mirrors the historical payload. - pub rate_limits: RateLimitSnapshot, - /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). - pub rate_limits_by_limit_id: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailParams { - pub credit_type: AddCreditsNudgeCreditType, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeCreditType { - Credits, - UsageLimit, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailResponse { - pub status: AddCreditsNudgeEmailStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountParams { - /// When `true`, requests a proactive token refresh before returning. - /// - /// In managed auth mode this triggers the normal refresh-token flow. In - /// external auth mode this flag is ignored. Clients should refresh tokens - /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] - pub refresh_token: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountResponse { - pub account: Option, - pub requires_openai_auth: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadResponse { - pub namespace_tools: bool, - pub image_generation: bool, - pub web_search: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// When true, include models that are hidden from the default picker list. - #[ts(optional = nullable)] - pub include_hidden: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelAvailabilityNux { - pub message: String, -} - -impl From for ModelAvailabilityNux { - fn from(value: CoreModelAvailabilityNux) -> Self { - Self { - message: value.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelServiceTier { - pub id: String, - pub name: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Model { - pub id: String, - pub model: String, - pub upgrade: Option, - pub upgrade_info: Option, - pub availability_nux: Option, - pub display_name: String, - pub description: String, - pub hidden: bool, - pub supported_reasoning_efforts: Vec, - pub default_reasoning_effort: ReasoningEffort, - #[serde(default = "default_input_modalities")] - pub input_modalities: Vec, - #[serde(default)] - pub supports_personality: bool, - /// Deprecated: use `serviceTiers` instead. - #[serde(default)] - pub additional_speed_tiers: Vec, - #[serde(default)] - pub service_tiers: Vec, - // Only one model should be marked as default. - pub is_default: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelUpgradeInfo { - pub model: String, - pub upgrade_copy: Option, - pub model_link: Option, - pub migration_markdown: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningEffortOption { - pub reasoning_effort: ReasoningEffort, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListResponse { - pub data: Vec, - /// 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, -} - -/// EXPERIMENTAL - list collaboration mode presets. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListParams {} - -/// EXPERIMENTAL - collaboration mode preset metadata for clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeMask { - pub name: String, - pub mode: Option, - pub model: Option, - #[serde(rename = "reasoning_effort")] - #[ts(rename = "reasoning_effort")] - pub reasoning_effort: Option>, -} - -impl From for CollaborationModeMask { - fn from(value: CoreCollaborationModeMask) -> Self { - Self { - name: value.name, - mode: value.mode, - model: value.model, - reasoning_effort: value.reasoning_effort, - } - } -} - -/// EXPERIMENTAL - collaboration mode presets response. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ExperimentalFeatureStage { - /// Feature is available for user testing and feedback. - Beta, - /// Feature is still being built and not ready for broad use. - UnderDevelopment, - /// Feature is production-ready. - Stable, - /// Feature is deprecated and should be avoided. - Deprecated, - /// Feature flag is retained only for backwards compatibility. - Removed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeature { - /// Stable key used in config.toml and CLI flag toggles. - pub name: String, - /// Lifecycle stage of this feature flag. - pub stage: ExperimentalFeatureStage, - /// User-facing display name shown in the experimental features UI. - /// Null when this feature is not in beta. - pub display_name: Option, - /// Short summary describing what the feature does. - /// Null when this feature is not in beta. - pub description: Option, - /// Announcement copy shown to users when the feature is introduced. - /// Null when this feature is not in beta. - pub announcement: Option, - /// Whether this feature is currently enabled in the loaded config. - pub enabled: bool, - /// Whether this feature is enabled by default. - pub default_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListResponse { - pub data: Vec, - /// 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, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetParams { - /// Process-wide runtime feature enablement keyed by canonical feature name. - /// - /// Only named features are updated. Omitted features are left unchanged. - /// Send an empty map for a no-op. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetResponse { - /// Feature enablement entries updated by this request. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a server-defined value. - #[ts(optional = nullable)] - pub limit: Option, - /// Controls how much MCP inventory data to fetch for each server. - /// Defaults to `Full` when omitted. - #[ts(optional = nullable)] - pub detail: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum McpServerStatusDetail { - Full, - ToolsAndAuthOnly, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatus { - pub name: String, - pub tools: std::collections::HashMap, - pub resources: Vec, - pub resource_templates: Vec, - pub auth_status: McpAuthStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusResponse { - pub data: Vec, - /// 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, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadParams { - #[ts(optional = nullable)] - pub thread_id: Option, - pub server: String, - pub uri: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadResponse { - pub contents: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallParams { - pub thread_id: String, - pub server: String, - pub tool: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub arguments: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallResponse { - pub content: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub structured_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub is_error: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -impl From for McpServerToolCallResponse { - fn from(result: CoreMcpCallToolResult) -> Self { - Self { - content: result.content, - structured_content: result.structured_content, - is_error: result.is_error, - meta: result.meta, - } - } -} - -impl From for McpToolCallResult { - fn from(result: CoreMcpCallToolResult) -> Self { - Self { - content: result.content, - structured_content: result.structured_content, - meta: result.meta, - } - } -} - -impl From for McpToolCallError { - fn from(error: CoreMcpToolCallError) -> Self { - Self { - message: error.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - list available apps/connectors. -pub struct AppsListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional thread id used to evaluate app feature gating from that thread's config. - #[ts(optional = nullable)] - pub thread_id: Option, - /// When true, bypass app caches and fetch the latest data from sources. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_refetch: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppBranding { - pub category: Option, - pub developer: Option, - pub website: Option, - pub privacy_policy: Option, - pub terms_of_service: Option, - pub is_discoverable_app: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppReview { - pub status: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppScreenshot { - pub url: Option, - #[serde(alias = "file_id")] - pub file_id: Option, - #[serde(alias = "user_prompt")] - pub user_prompt: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppMetadata { - pub review: Option, - pub categories: Option>, - pub sub_categories: Option>, - pub seo_description: Option, - pub screenshots: Option>, - pub developer: Option, - pub version: Option, - pub version_id: Option, - pub version_notes: Option, - pub first_party_type: Option, - pub first_party_requires_install: Option, - pub show_in_composer_when_unlinked: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppInfo { - pub id: String, - pub name: String, - pub description: Option, - pub logo_url: Option, - pub logo_url_dark: Option, - pub distribution_channel: Option, - pub branding: Option, - pub app_metadata: Option, - pub labels: Option>, - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, - /// Whether this app is enabled in config.toml. - /// Example: - /// ```toml - /// [apps.bad_app] - /// enabled = false - /// ``` - #[serde(default = "default_enabled")] - pub is_enabled: bool, - #[serde(default)] - pub plugin_display_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata summary for plugin responses. -pub struct AppSummary { - pub id: String, - pub name: String, - pub description: Option, - pub install_url: Option, - pub needs_auth: bool, -} - -impl From for AppSummary { - fn from(value: AppInfo) -> Self { - Self { - id: value.id, - name: value.name, - description: value.description, - install_url: value.install_url, - needs_auth: false, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app list response. -pub struct AppsListResponse { - pub data: Vec, - /// 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, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - notification emitted when the app list changes. -pub struct AppListUpdatedNotification { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginParams { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub scopes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub timeout_secs: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginResponse { - pub authorization_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadParams { - pub classification: String, - #[ts(optional = nullable)] - pub reason: Option, - #[ts(optional = nullable)] - pub thread_id: Option, - pub include_logs: bool, - #[ts(optional = nullable)] - pub extra_log_files: Option>, - #[ts(optional = nullable)] - pub tags: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadResponse { - pub thread_id: String, -} - -/// Device-key algorithm reported at enrollment and signing boundaries. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyAlgorithm { - EcdsaP256Sha256, -} - -/// Platform protection class for a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionClass { - HardwareSecureEnclave, - HardwareTpm, - OsProtectedNonextractable, -} - -/// Protection policy for creating or loading a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionPolicy { - HardwareOnly, - AllowOsProtectedNonextractable, -} - -/// Create a controller-local device key with a random key id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateParams { - /// Defaults to `hardware_only` when omitted. - #[ts(optional = nullable)] - pub protection_policy: Option, - pub account_user_id: String, - pub client_id: String, -} - -/// Device-key metadata and public key returned by create/public APIs. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Fetch a controller-local device key public key by id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicParams { - pub key_id: String, -} - -/// Device-key public metadata returned by `device/key/public`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Current remote-control connection status and environment id exposed to clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RemoteControlStatusChangedNotification { - pub status: RemoteControlConnectionStatus, - pub environment_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum RemoteControlConnectionStatus { - Disabled, - Connecting, - Connected, - Errored, -} - -/// Audience for a remote-control client connection device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientConnectionAudience { - RemoteControlClientWebsocket, -} - -/// Audience for a remote-control client enrollment device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientEnrollmentAudience { - RemoteControlClientEnrollment, -} - -/// Structured payloads accepted by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum DeviceKeySignPayload { - /// Payload bound to one remote-control controller websocket `/client` connection challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientConnection { - nonce: String, - audience: RemoteControlClientConnectionAudience, - /// Backend-issued websocket session id that this proof authorizes. - session_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// Websocket route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// Remote-control token expiration as Unix seconds. - #[ts(type = "number")] - token_expires_at: i64, - /// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. - token_sha256_base64url: String, - /// Must contain exactly `remote_control_controller_websocket`. - scopes: Vec, - }, - /// Payload bound to a remote-control client `/client/enroll` ownership challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientEnrollment { - nonce: String, - audience: RemoteControlClientEnrollmentAudience, - /// Backend-issued enrollment challenge id that this proof authorizes. - challenge_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// HTTP route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// SHA-256 of the requested device identity operation, encoded as unpadded base64url. - device_identity_sha256_base64url: String, - /// Enrollment challenge expiration as Unix seconds. - #[ts(type = "number")] - challenge_expires_at: i64, - }, -} - -/// Sign an accepted structured payload with a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignParams { - pub key_id: String, - pub payload: DeviceKeySignPayload, -} - -/// ASN.1 DER signature returned by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignResponse { - /// ECDSA signature DER encoded as base64. - pub signature_der_base64: String, - /// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte - /// string directly and must not reserialize `payload`. - pub signed_payload_base64: String, - pub algorithm: DeviceKeyAlgorithm, -} - -/// Read a file from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileParams { - /// Absolute path to read. - pub path: AbsolutePathBuf, -} - -/// Base64-encoded file contents returned by `fs/readFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileResponse { - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Write a file on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileParams { - /// Absolute path to write. - pub path: AbsolutePathBuf, - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Successful response for `fs/writeFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileResponse {} - -/// Create a directory on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryParams { - /// Absolute directory path to create. - pub path: AbsolutePathBuf, - /// Whether parent directories should also be created. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, -} - -/// Successful response for `fs/createDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryResponse {} - -/// Request metadata for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataParams { - /// Absolute path to inspect. - pub path: AbsolutePathBuf, -} - -/// Metadata returned by `fs/getMetadata`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataResponse { - /// Whether the path resolves to a directory. - pub is_directory: bool, - /// Whether the path resolves to a regular file. - pub is_file: bool, - /// Whether the path itself is a symbolic link. - pub is_symlink: bool, - /// File creation time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub created_at_ms: i64, - /// File modification time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub modified_at_ms: i64, -} - -/// List direct child names for a directory. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryParams { - /// Absolute directory path to read. - pub path: AbsolutePathBuf, -} - -/// A directory entry returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryEntry { - /// Direct child entry name only, not an absolute or relative path. - pub file_name: String, - /// Whether this entry resolves to a directory. - pub is_directory: bool, - /// Whether this entry resolves to a regular file. - pub is_file: bool, -} - -/// Directory entries returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryResponse { - /// Direct child entries in the requested directory. - pub entries: Vec, -} - -/// Remove a file or directory tree from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveParams { - /// Absolute path to remove. - pub path: AbsolutePathBuf, - /// Whether directory removal should recurse. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, - /// Whether missing paths should be ignored. Defaults to `true`. - #[ts(optional = nullable)] - pub force: Option, -} - -/// Successful response for `fs/remove`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveResponse {} - -/// Copy a file or directory tree on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyParams { - /// Absolute source path. - pub source_path: AbsolutePathBuf, - /// Absolute destination path. - pub destination_path: AbsolutePathBuf, - /// Required for directory copies; ignored for file copies. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub recursive: bool, -} - -/// Successful response for `fs/copy`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyResponse {} - -/// Start filesystem watch notifications for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchParams { - /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. - pub watch_id: String, - /// Absolute file or directory path to watch. - pub path: AbsolutePathBuf, -} - -/// Successful response for `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchResponse { - /// Canonicalized path associated with the watch. - pub path: AbsolutePathBuf, -} - -/// Stop filesystem watch notifications for a prior `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchParams { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, -} - -/// Successful response for `fs/unwatch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchResponse {} - -/// Filesystem watch notification emitted for `fs/watch` subscribers. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsChangedNotification { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, - /// File or directory paths associated with this event. - pub changed_paths: Vec, -} - -/// PTY size in character cells for `command/exec` PTY sessions. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminalSize { - /// Terminal height in character cells. - pub rows: u16, - /// Terminal width in character cells. - pub cols: u16, -} - -/// Run a standalone command (argv vector) in the server sandbox without -/// creating a thread or turn. -/// -/// The final `command/exec` response is deferred until the process exits and is -/// sent only after all `command/exec/outputDelta` notifications for that -/// connection have been emitted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecParams { - /// Command argv vector. Empty arrays are rejected. - pub command: Vec, - /// Optional client-supplied, connection-scoped process id. - /// - /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up - /// `command/exec/write`, `command/exec/resize`, and - /// `command/exec/terminate` calls. When omitted, buffered execution gets an - /// internal id that is not exposed to the client. - #[ts(optional = nullable)] - pub process_id: Option, - /// Enable PTY mode. - /// - /// This implies `streamStdin` and `streamStdoutStderr`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub tty: bool, - /// Allow follow-up `command/exec/write` requests to write stdin bytes. - /// - /// Requires a client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdin: bool, - /// Stream stdout/stderr via `command/exec/outputDelta` notifications. - /// - /// Streamed bytes are not duplicated into the final response and require a - /// client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdout_stderr: bool, - /// Optional per-stream stdout/stderr capture cap in bytes. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableOutputCap`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub output_bytes_cap: Option, - /// Disable stdout/stderr capture truncation for this request. - /// - /// Cannot be combined with `outputBytesCap`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_output_cap: bool, - /// Disable the timeout entirely for this request. - /// - /// Cannot be combined with `timeoutMs`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_timeout: bool, - /// Optional timeout in milliseconds. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableTimeout`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option, - /// Optional working directory. Defaults to the server cwd. - #[ts(optional = nullable)] - pub cwd: Option, - /// Optional environment overrides merged into the server-computed - /// environment. - /// - /// Matching names override inherited values. Set a key to `null` to unset - /// an inherited variable. - #[ts(optional = nullable)] - pub env: Option>>, - /// Optional initial PTY size in character cells. Only valid when `tty` is - /// true. - #[ts(optional = nullable)] - pub size: Option, - /// Optional sandbox policy for this command. - /// - /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. Cannot be - /// combined with `permissionProfile`. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Optional full permissions profile for this command. - /// - /// Defaults to the user's configured permissions when omitted. Cannot be - /// combined with `sandboxPolicy`. - #[experimental("command/exec.permissionProfile")] - #[ts(optional = nullable)] - pub permission_profile: Option, -} - -/// Final buffered result for `command/exec`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResponse { - /// Process exit code. - pub exit_code: i32, - /// Buffered stdout capture. - /// - /// Empty when stdout was streamed via `command/exec/outputDelta`. - pub stdout: String, - /// Buffered stderr capture. - /// - /// Empty when stderr was streamed via `command/exec/outputDelta`. - pub stderr: String, -} - -/// Write stdin bytes to a running `command/exec` session, close stdin, or -/// both. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Optional base64-encoded stdin bytes to write. - #[ts(optional = nullable)] - pub delta_base64: Option, - /// Close stdin after writing `deltaBase64`, if present. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close_stdin: bool, -} - -/// Empty success response for `command/exec/write`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteResponse {} - -/// Terminate a running `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, -} - -/// Empty success response for `command/exec/terminate`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateResponse {} - -/// Resize a running PTY-backed `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// New PTY size in character cells. - pub size: CommandExecTerminalSize, -} - -/// Empty success response for `command/exec/resize`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeResponse {} - -/// Stream label for `command/exec/outputDelta` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecOutputStream { - /// stdout stream. PTY mode multiplexes terminal output here. - Stdout, - /// stderr stream. - Stderr, -} - -/// PTY size in character cells for `process/spawn` PTY sessions. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessTerminalSize { - /// Terminal height in character cells. - pub rows: u16, - /// Terminal width in character cells. - pub cols: u16, -} - -/// Spawn a standalone process (argv vector) without a Codex sandbox on the host -/// where the app server is running. -/// -/// `process/spawn` returns after the process has started and the connection-scoped -/// `processHandle` has been registered. Process output and exit are reported via -/// `process/outputDelta` and `process/exited` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessSpawnParams { - /// Command argv vector. Empty arrays are rejected. - pub command: Vec, - /// Client-supplied, connection-scoped process handle. - /// - /// Duplicate active handles are rejected on the same connection. The same - /// handle can be reused after the prior process exits. - pub process_handle: String, - /// Absolute working directory for the process. - pub cwd: AbsolutePathBuf, - /// Enable PTY mode. - /// - /// This implies `streamStdin` and `streamStdoutStderr`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub tty: bool, - /// Allow follow-up `process/writeStdin` requests to write stdin bytes. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdin: bool, - /// Stream stdout/stderr via `process/outputDelta` notifications. - /// - /// Streamed bytes are not duplicated into the `process/exited` notification. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdout_stderr: bool, - /// Optional per-stream stdout/stderr capture cap in bytes. - /// - /// When omitted, the server default applies. Set to `null` to disable the - /// cap. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub output_bytes_cap: Option>, - /// Optional timeout in milliseconds. - /// - /// When omitted, the server default applies. Set to `null` to disable the - /// timeout. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option>, - /// Optional environment overrides merged into the app-server process - /// environment. - /// - /// Matching names override inherited values. Set a key to `null` to unset - /// an inherited variable. - #[ts(optional = nullable)] - pub env: Option>>, - /// Optional initial PTY size in character cells. Only valid when `tty` is - /// true. - #[ts(optional = nullable)] - pub size: Option, -} - -/// Successful response for `process/spawn`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessSpawnResponse {} - -/// Write stdin bytes to a running `process/spawn` session, close stdin, or -/// both. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessWriteStdinParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Optional base64-encoded stdin bytes to write. - #[ts(optional = nullable)] - pub delta_base64: Option, - /// Close stdin after writing `deltaBase64`, if present. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close_stdin: bool, -} - -/// Empty success response for `process/writeStdin`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessWriteStdinResponse {} - -/// Terminate a running `process/spawn` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessKillParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, -} - -/// Empty success response for `process/kill`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessKillResponse {} - -/// Resize a running PTY-backed `process/spawn` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessResizePtyParams { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// New PTY size in character cells. - pub size: ProcessTerminalSize, -} - -/// Empty success response for `process/resizePty`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessResizePtyResponse {} - -/// Stream label for `process/outputDelta` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ProcessOutputStream { - /// stdout stream. PTY mode multiplexes terminal output here. - Stdout, - /// stderr stream. - Stderr, -} - -/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessOutputDeltaNotification { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Output stream this chunk belongs to. - pub stream: ProcessOutputStream, - /// Base64-encoded output bytes. - pub delta_base64: String, - /// True on the final streamed chunk for this stream when output was - /// truncated by `outputBytesCap`. - pub cap_reached: bool, -} - -/// Final process exit notification for `process/spawn`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ProcessExitedNotification { - /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. - pub process_handle: String, - /// Process exit code. - pub exit_code: i32, - /// Buffered stdout capture. - /// - /// Empty when stdout was streamed via `process/outputDelta`. - pub stdout: String, - /// Whether stdout reached `outputBytesCap`. - /// - /// In streaming mode, stdout is empty and cap state is also reported on the - /// final stdout `process/outputDelta` notification. - pub stdout_cap_reached: bool, - /// Buffered stderr capture. - /// - /// Empty when stderr was streamed via `process/outputDelta`. - pub stderr: String, - /// Whether stderr reached `outputBytesCap`. - /// - /// In streaming mode, stderr is empty and cap state is also reported on the - /// final stderr `process/outputDelta` notification. - pub stderr_cap_reached: bool, -} - -// === Threads, Turns, and Items === -// Thread APIs -#[derive( - Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartParams { - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub service_name: Option, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - #[ts(optional = nullable)] - pub ephemeral: Option, - #[ts(optional = nullable)] - pub session_start_source: Option, - /// Optional sticky environments for this thread. - /// - /// Omitted selects the default environment when environment access is - /// enabled. Empty disables environment access for turns that do not - /// provide a turn override. Non-empty selects the first environment as the - /// current turn environment. - #[experimental("thread/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - #[experimental("thread/start.dynamicTools")] - #[ts(optional = nullable)] - pub dynamic_tools: Option>, - /// Test-only experimental field used to validate experimental gating and - /// schema filtering behavior in a stable way. - #[experimental("thread/start.mockExperimentalField")] - #[ts(optional = nullable)] - pub mock_experimental_field: Option, - /// If true, opt into emitting raw Responses API items on the event stream. - /// This is for internal use only (e.g. Codex Cloud). - #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] - pub experimental_raw_events: bool, - /// Deprecated and ignored by app-server. Kept only so older clients can - /// continue sending the field while rollout persistence always uses the - /// limited history policy. - #[experimental("thread/start.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodParams { - /// Test-only payload field. - #[ts(optional = nullable)] - pub value: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodResponse { - /// Echoes the input `value`. - pub echoed: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/start.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are three ways to resume a thread: -/// 1. By thread_id: load the thread from disk by thread_id and resume it. -/// 2. By history: instantiate the thread from memory and resume it. -/// 3. By path: load the thread from disk by path and resume it. -/// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadResumeParams { - pub thread_id: String, - - /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. - /// If specified, the thread will be resumed with the provided history - /// instead of loaded from disk. - #[experimental("thread/resume.history")] - #[ts(optional = nullable)] - pub history: Option>, - - /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/resume.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the resumed thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/resume.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - /// When true, return only thread metadata and live-resume state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after resuming. - #[experimental("thread/resume.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// Deprecated and ignored by app-server. Kept only so older clients can - /// continue sending the field while rollout persistence always uses the - /// limited history policy. - #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadResumeResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/resume.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are two ways to fork a thread: -/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. -/// 2. By path: load the thread from disk by path and fork it into a new thread. -/// -/// If using path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadForkParams { - pub thread_id: String, - - /// [UNSTABLE] Specify the rollout path to fork from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/fork.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the forked thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/fork.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub ephemeral: bool, - /// When true, return only thread metadata and live fork state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after forking. - #[experimental("thread/fork.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// Deprecated and ignored by app-server. Kept only so older clients can - /// continue sending the field while rollout persistence always uses the - /// limited history policy. - #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadForkResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/fork.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeResponse { - pub status: ThreadUnsubscribeStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadUnsubscribeStatus { - NotLoaded, - NotSubscribed, - Unsubscribed, -} - -/// Parameters for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be incremented. - pub thread_id: String, -} - -/// Response for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationResponse { - /// Current out-of-band elicitation count after the increment. - pub count: u64, - /// Whether timeout accounting is paused after applying the increment. - pub paused: bool, -} - -/// Parameters for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be decremented. - pub thread_id: String, -} - -/// Response for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationResponse { - /// Current out-of-band elicitation count after the decrement. - pub count: u64, - /// Whether timeout accounting remains paused after applying the decrement. - pub paused: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameParams { - pub thread_id: String, - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameResponse {} - -v2_enum_from_core! { - pub enum ThreadGoalStatus from CoreThreadGoalStatus { - Active, - Paused, - BudgetLimited, - Complete, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoal { - pub thread_id: String, - pub objective: String, - pub status: ThreadGoalStatus, - #[ts(type = "number | null")] - pub token_budget: Option, - #[ts(type = "number")] - pub tokens_used: i64, - #[ts(type = "number")] - pub time_used_seconds: i64, - #[ts(type = "number")] - pub created_at: i64, - #[ts(type = "number")] - pub updated_at: i64, -} - -impl From for ThreadGoal { - fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { - Self { - thread_id: value.thread_id.to_string(), - objective: value.objective, - status: value.status.into(), - token_budget: value.token_budget, - tokens_used: value.tokens_used, - time_used_seconds: value.time_used_seconds, - created_at: value.created_at, - updated_at: value.updated_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetParams { - pub thread_id: String, - #[ts(optional = nullable)] - pub objective: Option, - #[ts(optional = nullable)] - pub status: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable, type = "number | null")] - pub token_budget: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetResponse { - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetResponse { - pub goal: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearResponse { - pub cleared: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateParams { - pub thread_id: String, - /// Patch the stored Git metadata for this thread. - /// Omit a field to leave it unchanged, set it to `null` to clear it, or - /// provide a string to replace the stored value. - #[ts(optional = nullable)] - pub git_info: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataGitInfoUpdateParams { - /// Omit to leave the stored commit unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub sha: Option>, - /// Omit to leave the stored branch unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub branch: Option>, - /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub origin_url: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(rename_all = "lowercase")] -pub enum ThreadMemoryMode { - Enabled, - Disabled, -} - -impl ThreadMemoryMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Enabled => "enabled", - Self::Disabled => "disabled", - } - } - - pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { - match self { - Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, - Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetParams { - pub thread_id: String, - pub mode: ThreadMemoryMode, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryResetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandParams { - pub thread_id: String, - /// Shell command string evaluated by the thread's configured shell. - /// Unlike `command/exec`, this intentionally preserves shell syntax - /// such as pipes, redirects, and quoting. This runs unsandboxed with full - /// access rather than inheriting the thread sandbox policy. - pub command: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionParams { - pub thread_id: String, - /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. - pub event: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackParams { - pub thread_id: String, - /// The number of turns to drop from the end of the thread. Must be >= 1. - /// - /// This only modifies the thread's history and does not revert local file changes - /// that have been made by the agent. Clients are responsible for reverting these changes. - pub num_turns: u32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackResponse { - /// The updated thread after applying the rollback, with `turns` populated. - /// - /// The ThreadItems stored in each Turn are lossy since we explicitly do not - /// persist all agent interactions, such as command executions. This is the same - /// behavior as `thread/resume`. - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional sort key; defaults to created_at. - #[ts(optional = nullable)] - pub sort_key: Option, - /// Optional sort direction; defaults to descending (newest first). - #[ts(optional = nullable)] - pub sort_direction: Option, - /// Optional provider filter; when set, only sessions recorded under these - /// providers are returned. When present but empty, includes all providers. - #[ts(optional = nullable)] - pub model_providers: Option>, - /// 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>, - /// 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, - /// Optional cwd filter or filters; when set, only threads whose session cwd - /// exactly matches one of these paths are returned. - #[ts(optional = nullable, type = "string | Array | null")] - pub cwd: Option, - /// If true, return from the state DB without scanning JSONL rollouts to - /// repair thread metadata. Omitted or false preserves scan-and-repair - /// behavior. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub use_state_db_only: bool, - /// Optional substring filter for the extracted thread title. - #[ts(optional = nullable)] - pub search_term: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum ThreadListCwdFilter { - One(String), - Many(Vec), -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadSourceKind { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - VsCode, - Exec, - AppServer, - SubAgent, - SubAgentReview, - SubAgentCompact, - SubAgentThreadSpawn, - SubAgentOther, - Unknown, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum ThreadSortKey { - CreatedAt, - UpdatedAt, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SortDirection { - Asc, - Desc, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListResponse { - pub data: Vec, - /// 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, - /// 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, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to no limit. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListResponse { - /// Thread ids for sessions currently loaded in memory. - pub data: Vec, - /// 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, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadStatus { - NotLoaded, - Idle, - SystemError, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Active { - active_flags: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadActiveFlag { - WaitingOnApproval, - WaitingOnUserInput, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadParams { - pub thread_id: String, - /// When true, include turns and their items from rollout history. - #[serde(default)] - pub include_turns: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListParams { - pub thread_id: String, - /// Opaque cursor to pass to the next call to continue after the last turn. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional turn page size. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional turn pagination direction; defaults to descending. - #[ts(optional = nullable)] - pub sort_direction: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last turn. - /// if None, there are no more turns to return. - pub next_cursor: Option, - /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. - /// This is only populated when the page contains at least one turn. - /// Use it with the opposite `sortDirection` to include the anchor turn again - /// and catch updates to that turn. - pub backwards_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, - - /// When true, bypass the skills cache and re-scan skills from disk. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_reload: bool, - - /// Optional per-cwd extra roots to scan as user-scoped skills. - #[serde(default)] - #[ts(optional = nullable)] - pub per_cwd_extra_user_roots: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListExtraRootsForCwd { - pub cwd: PathBuf, - pub extra_user_roots: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddParams { - pub source: String, - #[ts(optional = nullable)] - pub ref_name: Option, - #[ts(optional = nullable)] - pub sparse_paths: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddResponse { - pub marketplace_name: String, - pub installed_root: AbsolutePathBuf, - pub already_added: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveParams { - pub marketplace_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveResponse { - pub marketplace_name: String, - pub installed_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeParams { - #[ts(optional = nullable)] - pub marketplace_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeResponse { - pub selected_marketplaces: Vec, - pub upgraded_roots: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeErrorInfo { - pub marketplace_name: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListParams { - /// Optional working directories used to discover repo marketplaces. When omitted, - /// only home-scoped marketplaces and the official curated marketplace are considered. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListResponse { - pub marketplaces: Vec, - #[serde(default)] - pub marketplace_load_errors: Vec, - #[serde(default)] - pub featured_plugin_ids: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceLoadErrorInfo { - pub marketplace_path: AbsolutePathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadResponse { - pub plugin: PluginDetail, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadParams { - pub remote_marketplace_name: String, - pub remote_plugin_id: String, - pub skill_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadResponse { - pub contents: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveParams { - pub plugin_path: AbsolutePathBuf, - #[ts(optional = nullable)] - pub remote_plugin_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveResponse { - pub remote_plugin_id: String, - pub share_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteParams { - pub remote_plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListItem { - pub plugin: PluginSummary, - pub share_url: String, - pub local_plugin_path: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SkillScope { - User, - Repo, - System, - Admin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillMetadata { - pub name: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. - pub short_description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub interface: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub dependencies: Option, - pub path: AbsolutePathBuf, - pub scope: SkillScope, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillInterface { - #[ts(optional)] - pub display_name: Option, - #[ts(optional)] - pub short_description: Option, - #[ts(optional)] - pub icon_small: Option, - #[ts(optional)] - pub icon_large: Option, - #[ts(optional)] - pub brand_color: Option, - #[ts(optional)] - pub default_prompt: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillDependencies { - pub tools: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillToolDependency { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub r#type: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub transport: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub url: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListEntry { - pub cwd: PathBuf, - pub hooks: Vec, - pub warnings: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMetadata { - pub key: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub matcher: Option, - pub command: Option, - pub timeout_sec: u64, - pub status_message: Option, - pub source_path: AbsolutePathBuf, - pub source: HookSource, - pub plugin_id: Option, - pub display_order: i64, - pub enabled: bool, - pub is_managed: bool, - pub current_hash: String, - pub trust_status: HookTrustStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginMarketplaceEntry { - pub name: String, - /// Local marketplace file path when the marketplace is backed by a local file. - /// Remote-only catalog marketplaces do not have a local path. - pub path: Option, - pub interface: Option, - pub plugins: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceInterface { - pub display_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginInstallPolicy { - #[serde(rename = "NOT_AVAILABLE")] - #[ts(rename = "NOT_AVAILABLE")] - NotAvailable, - #[serde(rename = "AVAILABLE")] - #[ts(rename = "AVAILABLE")] - Available, - #[serde(rename = "INSTALLED_BY_DEFAULT")] - #[ts(rename = "INSTALLED_BY_DEFAULT")] - InstalledByDefault, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAuthPolicy { - #[serde(rename = "ON_INSTALL")] - #[ts(rename = "ON_INSTALL")] - OnInstall, - #[serde(rename = "ON_USE")] - #[ts(rename = "ON_USE")] - OnUse, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAvailability { - /// Plugin-service currently sends `"ENABLED"` for available remote plugins. - /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps - /// decoding compatible with that upstream response. - #[serde(rename = "AVAILABLE", alias = "ENABLED")] - #[ts(rename = "AVAILABLE")] - #[default] - Available, - #[serde(rename = "DISABLED_BY_ADMIN")] - #[ts(rename = "DISABLED_BY_ADMIN")] - DisabledByAdmin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSummary { - pub id: String, - pub name: String, - pub source: PluginSource, - pub installed: bool, - pub enabled: bool, - pub install_policy: PluginInstallPolicy, - pub auth_policy: PluginAuthPolicy, - /// Availability state for installing and using the plugin. - #[serde(default)] - pub availability: PluginAvailability, - pub interface: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginDetail { - pub marketplace_name: String, - pub marketplace_path: Option, - pub summary: PluginSummary, - pub description: Option, - pub skills: Vec, - pub apps: Vec, - pub mcp_servers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillSummary { - pub name: String, - pub description: String, - pub short_description: Option, - pub interface: Option, - pub path: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInterface { - pub display_name: Option, - pub short_description: Option, - pub long_description: Option, - pub developer_name: Option, - pub category: Option, - pub capabilities: Vec, - pub website_url: Option, - pub privacy_policy_url: Option, - pub terms_of_service_url: Option, - /// Starter prompts for the plugin. Capped at 3 entries with a maximum of - /// 128 characters per entry. - pub default_prompt: Option>, - pub brand_color: Option, - /// Local composer icon path, resolved from the installed plugin package. - pub composer_icon: Option, - /// Remote composer icon URL from the plugin catalog. - pub composer_icon_url: Option, - /// Local logo path, resolved from the installed plugin package. - pub logo: Option, - /// Remote logo URL from the plugin catalog. - pub logo_url: Option, - /// Local screenshot paths, resolved from the installed plugin package. - pub screenshots: Vec, - /// Remote screenshot URLs from the plugin catalog. - pub screenshot_urls: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PluginSource { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Local { path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Git { - url: String, - path: Option, - ref_name: Option, - sha: Option, - }, - /// The plugin is available in the remote catalog. Download metadata is - /// kept server-side and is not exposed through the app-server API. - Remote, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteParams { - /// Path-based selector. - #[ts(optional = nullable)] - pub path: Option, - /// Name-based selector. - #[ts(optional = nullable)] - pub name: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteResponse { - pub effective_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallResponse { - pub auth_policy: PluginAuthPolicy, - pub apps_needing_auth: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallParams { - pub plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallResponse {} - -impl From for SkillMetadata { - fn from(value: CoreSkillMetadata) -> Self { - Self { - name: value.name, - description: value.description, - short_description: value.short_description, - interface: value.interface.map(SkillInterface::from), - dependencies: value.dependencies.map(SkillDependencies::from), - path: value.path, - scope: value.scope.into(), - enabled: true, - } - } -} - -impl From for SkillInterface { - fn from(value: CoreSkillInterface) -> Self { - Self { - display_name: value.display_name, - short_description: value.short_description, - brand_color: value.brand_color, - default_prompt: value.default_prompt, - icon_small: value.icon_small, - icon_large: value.icon_large, - } - } -} - -impl From for SkillDependencies { - fn from(value: CoreSkillDependencies) -> Self { - Self { - tools: value - .tools - .into_iter() - .map(SkillToolDependency::from) - .collect(), - } - } -} - -impl From for SkillToolDependency { - fn from(value: CoreSkillToolDependency) -> Self { - Self { - r#type: value.r#type, - value: value.value, - description: value.description, - transport: value.transport, - command: value.command, - url: value.url, - } - } -} - -impl From for SkillScope { - fn from(value: CoreSkillScope) -> Self { - match value { - CoreSkillScope::User => Self::User, - CoreSkillScope::Repo => Self::Repo, - CoreSkillScope::System => Self::System, - CoreSkillScope::Admin => Self::Admin, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Thread { - pub id: String, - /// Source thread id when this thread was created by forking another thread. - pub forked_from_id: Option, - /// Usually the first user message in the thread, if available. - pub preview: String, - /// Whether the thread is ephemeral and should not be materialized on disk. - pub ephemeral: bool, - /// Model provider used for this thread (for example, 'openai'). - pub model_provider: String, - /// Unix timestamp (in seconds) when the thread was created. - #[ts(type = "number")] - pub created_at: i64, - /// Unix timestamp (in seconds) when the thread was last updated. - #[ts(type = "number")] - pub updated_at: i64, - /// Current runtime status for the thread. - pub status: ThreadStatus, - /// [UNSTABLE] Path to the thread on disk. - pub path: Option, - /// Working directory captured for the thread. - pub cwd: AbsolutePathBuf, - /// Version of the CLI that created the thread. - pub cli_version: String, - /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). - pub source: SessionSource, - /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. - pub agent_nickname: Option, - /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. - pub agent_role: Option, - /// Optional Git metadata captured when the thread was created. - pub git_info: Option, - /// Optional user-facing thread title. - pub name: Option, - /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` - /// (when `includeTurns` is true) responses. - /// For all other responses and notifications returning a Thread, - /// the turns field will be an empty list. - pub turns: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountUpdatedNotification { - pub auth_mode: Option, - pub plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsageUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub token_usage: ThreadTokenUsage, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsage { - pub total: TokenUsageBreakdown, - pub last: TokenUsageBreakdown, - // TODO(aibrahim): make this not optional - #[ts(type = "number | null")] - pub model_context_window: Option, -} - -impl From for ThreadTokenUsage { - fn from(value: CoreTokenUsageInfo) -> Self { - Self { - total: value.total_token_usage.into(), - last: value.last_token_usage.into(), - model_context_window: value.model_context_window, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TokenUsageBreakdown { - #[ts(type = "number")] - pub total_tokens: i64, - #[ts(type = "number")] - pub input_tokens: i64, - #[ts(type = "number")] - pub cached_input_tokens: i64, - #[ts(type = "number")] - pub output_tokens: i64, - #[ts(type = "number")] - pub reasoning_output_tokens: i64, -} - -impl From for TokenUsageBreakdown { - fn from(value: CoreTokenUsage) -> Self { - Self { - total_tokens: value.total_tokens, - input_tokens: value.input_tokens, - cached_input_tokens: value.cached_input_tokens, - output_tokens: value.output_tokens, - reasoning_output_tokens: value.reasoning_output_tokens, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Turn { - pub id: String, - /// Thread items currently included in this turn payload. - pub items: Vec, - /// Describes how much of `items` has been loaded for this turn. - #[serde(default)] - pub items_view: TurnItemsView, - pub status: TurnStatus, - /// Only populated when the Turn's status is failed. - pub error: Option, - /// Unix timestamp (in seconds) when the turn started. - #[ts(type = "number | null")] - pub started_at: Option, - /// Unix timestamp (in seconds) when the turn completed. - #[ts(type = "number | null")] - pub completed_at: Option, - /// Duration between turn start and completion in milliseconds, if known. - #[ts(type = "number | null")] - pub duration_ms: Option, -} - -#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnItemsView { - /// `items` was not loaded for this turn. The field is intentionally empty. - NotLoaded, - /// `items` contains only a display summary for this turn. - Summary, - /// `items` contains every ThreadItem available from persisted app-server history for this turn. - #[default] - Full, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitation { - pub entries: Vec, - pub thread_ids: Vec, -} - -impl From for MemoryCitation { - fn from(value: CoreMemoryCitation) -> Self { - Self { - entries: value.entries.into_iter().map(Into::into).collect(), - thread_ids: value.rollout_ids, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitationEntry { - pub path: String, - pub line_start: u32, - pub line_end: u32, - pub note: String, -} - -impl From for MemoryCitationEntry { - fn from(value: CoreMemoryCitationEntry) -> Self { - Self { - path: value.path, - line_start: value.line_start, - line_end: value.line_end, - note: value.note, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -#[error("{message}")] -pub struct TurnError { - pub message: String, - pub codex_error_info: Option, - #[serde(default)] - pub additional_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ErrorNotification { - pub error: TurnError, - // Set to true if the error is transient and the app-server process will automatically retry. - // If true, this will not interrupt a turn. - pub will_retry: bool, - pub thread_id: String, - pub turn_id: String, -} - -/// EXPERIMENTAL - thread realtime audio chunk. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAudioChunk { - pub data: String, - pub sample_rate: u32, - pub num_channels: u16, - pub samples_per_channel: Option, - pub item_id: Option, -} - -impl From for ThreadRealtimeAudioChunk { - fn from(value: CoreRealtimeAudioFrame) -> Self { - let CoreRealtimeAudioFrame { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -impl From for CoreRealtimeAudioFrame { - fn from(value: ThreadRealtimeAudioChunk) -> Self { - let ThreadRealtimeAudioChunk { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -/// EXPERIMENTAL - start a thread-scoped realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartParams { - pub thread_id: String, - /// Selects text or audio output for the realtime session. Transport and voice stay - /// independent so clients can choose how they connect separately from what the model emits. - pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub prompt: Option>, - #[ts(optional = nullable)] - pub realtime_session_id: Option, - #[ts(optional = nullable)] - pub transport: Option, - #[ts(optional = nullable)] - pub voice: Option, -} - -/// EXPERIMENTAL - transport used by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/", tag = "type")] -pub enum ThreadRealtimeStartTransport { - Websocket, - Webrtc { - /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the - /// realtime events data channel. - sdp: String, - }, -} - -/// EXPERIMENTAL - response for starting thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartResponse {} - -/// EXPERIMENTAL - append audio input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioParams { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - response for appending realtime audio input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioResponse {} - -/// EXPERIMENTAL - append text input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextParams { - pub thread_id: String, - pub text: String, -} - -/// EXPERIMENTAL - response for appending realtime text input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextResponse {} - -/// EXPERIMENTAL - stop thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopParams { - pub thread_id: String, -} - -/// EXPERIMENTAL - response for stopping thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopResponse {} - -/// EXPERIMENTAL - list voices supported by thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesParams {} - -/// EXPERIMENTAL - response for listing supported realtime voices. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesResponse { - pub voices: RealtimeVoicesList, -} - -/// EXPERIMENTAL - emitted when thread realtime startup is accepted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartedNotification { - pub thread_id: String, - pub realtime_session_id: Option, - pub version: RealtimeConversationVersion, -} - -/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeItemAddedNotification { - pub thread_id: String, - pub item: JsonValue, -} - -/// EXPERIMENTAL - flat transcript delta emitted whenever realtime -/// transcript text changes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDeltaNotification { - pub thread_id: String, - pub role: String, - /// Live transcript delta from the realtime event. - pub delta: String, -} - -/// EXPERIMENTAL - final transcript text emitted when realtime completes -/// a transcript part. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDoneNotification { - pub thread_id: String, - pub role: String, - /// Final complete text for the transcript part. - pub text: String, -} - -/// EXPERIMENTAL - streamed output audio emitted by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeOutputAudioDeltaNotification { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeSdpNotification { - pub thread_id: String, - pub sdp: String, -} - -/// EXPERIMENTAL - emitted when thread realtime encounters an error. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeErrorNotification { - pub thread_id: String, - pub message: String, -} - -/// EXPERIMENTAL - emitted when thread realtime transport closes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeClosedNotification { - pub thread_id: String, - pub reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnStatus { - Completed, - Interrupted, - Failed, - InProgress, -} - -// Turn APIs -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnEnvironmentParams { - pub environment_id: String, - pub cwd: AbsolutePathBuf, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/start.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Optional turn-scoped environments. - /// - /// Omitted uses the thread sticky environments. Empty disables - /// environment access for this turn. Non-empty selects the first - /// environment as the current turn environment for this turn. - #[experimental("turn/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - /// Override the working directory for this turn and subsequent turns. - #[ts(optional = nullable)] - pub cwd: Option, - /// Override the approval policy for this turn and subsequent turns. - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this turn and - /// subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. - #[experimental("turn/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - /// Override the model for this turn and subsequent turns. - #[ts(optional = nullable)] - pub model: Option, - /// Override the service tier for this turn and subsequent turns. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - /// Override the reasoning effort for this turn and subsequent turns. - #[ts(optional = nullable)] - pub effort: Option, - /// Override the reasoning summary for this turn and subsequent turns. - #[ts(optional = nullable)] - pub summary: Option, - /// Override the personality for this turn and subsequent turns. - #[ts(optional = nullable)] - pub personality: Option, - /// Optional JSON Schema used to constrain the final assistant message for - /// this turn. - #[ts(optional = nullable)] - pub output_schema: Option, - - /// EXPERIMENTAL - Set a pre-set collaboration mode. - /// Takes precedence over model, reasoning_effort, and developer instructions if set. - /// - /// For `collaboration_mode.settings.developer_instructions`, `null` means - /// "use the built-in instructions for the selected mode". - #[experimental("turn/start.collaborationMode")] - #[ts(optional = nullable)] - pub collaboration_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartParams { - pub thread_id: String, - pub target: ReviewTarget, - - /// Where to run the review: inline (default) on the current thread or - /// detached on a new thread (returned in `reviewThreadId`). - #[serde(default)] - #[ts(optional = nullable)] - pub delivery: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartResponse { - pub turn: Turn, - /// Identifies the thread where the review runs. - /// - /// For inline reviews, this is the original thread id. - /// For detached reviews, this is the id of the new review thread. - pub review_thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ReviewTarget { - /// Review the working tree: staged, unstaged, and untracked files. - UncommittedChanges, - - /// Review changes between the current branch and the given base branch. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - BaseBranch { branch: String }, - - /// Review the changes introduced by a specific commit. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Commit { - sha: String, - /// Optional human-readable label (e.g., commit subject) for UIs. - title: Option, - }, - - /// Arbitrary instructions, equivalent to the old free-form prompt. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Custom { instructions: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartResponse { - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsParams { - pub thread_id: String, - /// Raw Responses API items to append to the thread's model-visible history. - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsResponse {} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/steer.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Required active turn id precondition. The request fails when it does not - /// match the currently active turn. - pub expected_turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerResponse { - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptParams { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptResponse {} - -// User input types -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ByteRange { - pub start: usize, - pub end: usize, -} - -impl From for ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From for CoreByteRange { - fn from(value: ByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextElement { - /// Byte range in the parent `text` buffer that this element occupies. - pub byte_range: ByteRange, - /// Optional human-readable placeholder for the element, displayed in the UI. - placeholder: Option, -} - -impl TextElement { - pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { - Self { - byte_range, - placeholder, - } - } - - pub fn set_placeholder(&mut self, placeholder: Option) { - self.placeholder = placeholder; - } - - pub fn placeholder(&self) -> Option<&str> { - self.placeholder.as_deref() - } -} - -impl From for TextElement { - fn from(value: CoreTextElement) -> Self { - Self::new( - value.byte_range.into(), - value._placeholder_for_conversion_only().map(str::to_string), - ) - } -} - -impl From for CoreTextElement { - fn from(value: TextElement) -> Self { - Self::new(value.byte_range.into(), value.placeholder) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum UserInput { - Text { - text: String, - /// UI-defined spans within `text` used to render or persist special elements. - #[serde(default)] - text_elements: Vec, - }, - Image { - url: String, - }, - LocalImage { - path: PathBuf, - }, - Skill { - name: String, - path: PathBuf, - }, - Mention { - name: String, - path: String, - }, -} - -impl UserInput { - pub fn into_core(self) -> CoreUserInput { - match self { - UserInput::Text { - text, - text_elements, - } => CoreUserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, - UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, - UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, - } - } -} - -impl From for UserInput { - fn from(value: CoreUserInput) -> Self { - match value { - CoreUserInput::Text { - text, - text_elements, - } => UserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, - CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, - CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, - _ => unreachable!("unsupported user input variant"), - } - } -} - -impl UserInput { - pub fn text_char_count(&self) -> usize { - match self { - UserInput::Text { text, .. } => text.chars().count(), - UserInput::Image { .. } - | UserInput::LocalImage { .. } - | UserInput::Skill { .. } - | UserInput::Mention { .. } => 0, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadItem { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - HookPrompt { - id: String, - fragments: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AgentMessage { - id: String, - text: String, - #[serde(default)] - phase: Option, - #[serde(default)] - memory_citation: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - /// EXPERIMENTAL - proposed plan item content. The completed plan item is - /// authoritative and may not match the concatenation of `PlanDelta` text. - Plan { id: String, text: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - #[serde(default)] - content: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CommandExecution { - id: String, - /// The command to be executed. - command: String, - /// The command's working directory. - cwd: AbsolutePathBuf, - /// Identifier for the underlying PTY process (when available). - process_id: Option, - #[serde(default)] - source: CommandExecutionSource, - status: CommandExecutionStatus, - /// A best-effort parsing of the command to understand the action(s) it will perform. - /// This returns a list of CommandAction objects because a single shell command may - /// be composed of many commands piped together. - command_actions: Vec, - /// The command's output, aggregated from stdout and stderr. - aggregated_output: Option, - /// The command's exit code. - exit_code: Option, - /// The duration of the command execution in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - FileChange { - id: String, - changes: Vec, - status: PatchApplyStatus, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - id: String, - server: String, - tool: String, - status: McpToolCallStatus, - arguments: JsonValue, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - mcp_app_resource_uri: Option, - result: Option>, - error: Option, - /// The duration of the MCP tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - DynamicToolCall { - id: String, - namespace: Option, - tool: String, - arguments: JsonValue, - status: DynamicToolCallStatus, - content_items: Option>, - success: Option, - /// The duration of the dynamic tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CollabAgentToolCall { - /// Unique identifier for this collab tool call. - id: String, - /// Name of the collab tool that was invoked. - tool: CollabAgentTool, - /// Current status of the collab tool call. - status: CollabAgentToolCallStatus, - /// Thread ID of the agent issuing the collab request. - sender_thread_id: String, - /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this corresponds to the newly spawned agent. - receiver_thread_ids: Vec, - /// Prompt text sent as part of the collab tool call, when available. - prompt: Option, - /// Model requested for the spawned agent, when applicable. - model: Option, - /// Reasoning effort requested for the spawned agent, when applicable. - reasoning_effort: Option, - /// Last known status of the target agents, when available. - agents_states: HashMap, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageView { id: String, path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageGeneration { - id: String, - status: String, - revised_prompt: Option, - result: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - saved_path: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - EnteredReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExitedReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ContextCompaction { id: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub struct HookPromptFragment { - pub text: String, - pub hook_run_id: String, -} - -impl ThreadItem { - pub fn id(&self) -> &str { - match self { - ThreadItem::UserMessage { id, .. } - | ThreadItem::HookPrompt { id, .. } - | ThreadItem::AgentMessage { id, .. } - | ThreadItem::Plan { id, .. } - | ThreadItem::Reasoning { id, .. } - | ThreadItem::CommandExecution { id, .. } - | ThreadItem::FileChange { id, .. } - | ThreadItem::McpToolCall { id, .. } - | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } - | ThreadItem::ImageView { id, .. } - | ThreadItem::ImageGeneration { id, .. } - | ThreadItem::EnteredReviewMode { id, .. } - | ThreadItem::ExitedReviewMode { id, .. } - | ThreadItem::ContextCompaction { id, .. } => id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Lifecycle state for an approval auto-review. -pub enum GuardianApprovalReviewStatus { - InProgress, - Approved, - Denied, - TimedOut, - Aborted, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Source that produced a terminal approval auto-review decision. -pub enum AutoReviewDecisionSource { - Agent, -} - -impl From for AutoReviewDecisionSource { - fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { - match value { - CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Risk level assigned by approval auto-review. -pub enum GuardianRiskLevel { - Low, - Medium, - High, - Critical, -} - -impl From for GuardianRiskLevel { - fn from(value: CoreGuardianRiskLevel) -> Self { - match value { - CoreGuardianRiskLevel::Low => Self::Low, - CoreGuardianRiskLevel::Medium => Self::Medium, - CoreGuardianRiskLevel::High => Self::High, - CoreGuardianRiskLevel::Critical => Self::Critical, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Authorization level assigned by approval auto-review. -pub enum GuardianUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -impl From for GuardianUserAuthorization { - fn from(value: CoreGuardianUserAuthorization) -> Self { - match value { - CoreGuardianUserAuthorization::Unknown => Self::Unknown, - CoreGuardianUserAuthorization::Low => Self::Low, - CoreGuardianUserAuthorization::Medium => Self::Medium, - CoreGuardianUserAuthorization::High => Self::High, - } - } -} - -/// [UNSTABLE] Temporary approval auto-review payload used by -/// `item/autoApprovalReview/*` notifications. This shape is expected to change -/// soon. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApprovalReview { - pub status: GuardianApprovalReviewStatus, - pub risk_level: Option, - pub user_authorization: Option, - pub rationale: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum GuardianCommandSource { - Shell, - UnifiedExec, -} - -impl From for GuardianCommandSource { - fn from(value: CoreGuardianCommandSource) -> Self { - match value { - CoreGuardianCommandSource::Shell => Self::Shell, - CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, - } - } -} - -impl From for CoreGuardianCommandSource { - fn from(value: GuardianCommandSource) -> Self { - match value { - GuardianCommandSource::Shell => Self::Shell, - GuardianCommandSource::UnifiedExec => Self::UnifiedExec, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianCommandReviewAction { - pub source: GuardianCommandSource, - pub command: String, - pub cwd: AbsolutePathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianExecveReviewAction { - pub source: GuardianCommandSource, - pub program: String, - pub argv: Vec, - pub cwd: AbsolutePathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApplyPatchReviewAction { - pub cwd: AbsolutePathBuf, - pub files: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianNetworkAccessReviewAction { - pub target: String, - pub host: String, - pub protocol: NetworkApprovalProtocol, - pub port: u16, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianMcpToolCallReviewAction { - pub server: String, - pub tool_name: String, - pub connector_id: Option, - pub connector_name: Option, - pub tool_title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianRequestPermissionsReviewAction { - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum GuardianApprovalReviewAction { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Command { - source: GuardianCommandSource, - command: String, - cwd: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Execve { - source: GuardianCommandSource, - program: String, - argv: Vec, - cwd: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ApplyPatch { - cwd: AbsolutePathBuf, - files: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - NetworkAccess { - target: String, - host: String, - protocol: NetworkApprovalProtocol, - port: u16, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - server: String, - tool_name: String, - connector_id: Option, - connector_name: Option, - tool_title: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RequestPermissions { - reason: Option, - permissions: RequestPermissionProfile, - }, -} - -impl From for GuardianApprovalReviewAction { - fn from(value: CoreGuardianAssessmentAction) -> Self { - match value { - CoreGuardianAssessmentAction::Command { - source, - command, - cwd, - } => Self::Command { - source: source.into(), - command, - cwd, - }, - CoreGuardianAssessmentAction::Execve { - source, - program, - argv, - cwd, - } => Self::Execve { - source: source.into(), - program, - argv, - cwd, - }, - CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { - Self::ApplyPatch { cwd, files } - } - CoreGuardianAssessmentAction::NetworkAccess { - target, - host, - protocol, - port, - } => Self::NetworkAccess { - target, - host, - protocol: protocol.into(), - port, - }, - CoreGuardianAssessmentAction::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - } => Self::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - }, - CoreGuardianAssessmentAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -impl From for CoreGuardianAssessmentAction { - fn from(value: GuardianApprovalReviewAction) -> Self { - match value { - GuardianApprovalReviewAction::Command { - source, - command, - cwd, - } => Self::Command { - source: source.into(), - command, - cwd, - }, - GuardianApprovalReviewAction::Execve { - source, - program, - argv, - cwd, - } => Self::Execve { - source: source.into(), - program, - argv, - cwd, - }, - GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { - Self::ApplyPatch { cwd, files } - } - GuardianApprovalReviewAction::NetworkAccess { - target, - host, - protocol, - port, - } => Self::NetworkAccess { - target, - host, - protocol: protocol.to_core(), - port, - }, - GuardianApprovalReviewAction::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - } => Self::McpToolCall { - server, - tool_name, - connector_id, - connector_name, - tool_title, - }, - GuardianApprovalReviewAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: codex_protocol::models::WebSearchAction) -> Self { - match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - codex_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, - } - } -} - -impl From for ThreadItem { - fn from(value: CoreTurnItem) -> Self { - match value { - CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { - id: user.id, - content: user.content.into_iter().map(UserInput::from).collect(), - }, - CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { - id: hook_prompt.id, - fragments: hook_prompt - .fragments - .into_iter() - .map(HookPromptFragment::from) - .collect(), - }, - CoreTurnItem::AgentMessage(agent) => { - let text = agent - .content - .into_iter() - .map(|entry| match entry { - CoreAgentMessageContent::Text { text } => text, - }) - .collect::(); - ThreadItem::AgentMessage { - id: agent.id, - text, - phase: agent.phase, - memory_citation: agent.memory_citation.map(Into::into), - } - } - CoreTurnItem::Plan(plan) => ThreadItem::Plan { - id: plan.id, - text: plan.text, - }, - CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { - id: reasoning.id, - summary: reasoning.summary_text, - content: reasoning.raw_content, - }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { - id: search.id, - query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, - CoreTurnItem::ImageView(image) => ThreadItem::ImageView { - id: image.id, - path: image.path, - }, - CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - result: image.result, - saved_path: image.saved_path, - }, - CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { - id: file_change.id, - changes: convert_patch_changes(&file_change.changes), - status: file_change - .status - .as_ref() - .map(PatchApplyStatus::from) - .unwrap_or(PatchApplyStatus::InProgress), - }, - CoreTurnItem::McpToolCall(mcp) => { - let duration_ms = mcp - .duration - .and_then(|duration| i64::try_from(duration.as_millis()).ok()); - - ThreadItem::McpToolCall { - id: mcp.id, - server: mcp.server, - tool: mcp.tool, - status: McpToolCallStatus::from(mcp.status), - arguments: mcp.arguments, - mcp_app_resource_uri: mcp.mcp_app_resource_uri, - result: mcp.result.map(McpToolCallResult::from).map(Box::new), - error: mcp.error.map(McpToolCallError::from), - duration_ms, - } - } - CoreTurnItem::ContextCompaction(compaction) => { - ThreadItem::ContextCompaction { id: compaction.id } - } - } - } -} - -impl From for HookPromptFragment { - fn from(value: codex_protocol::items::HookPromptFragment) -> Self { - Self { - text: value.text, - hook_run_id: value.hook_run_id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for CommandExecutionStatus { - fn from(value: CoreExecCommandStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CoreExecCommandStatus> for CommandExecutionStatus { - fn from(value: &CoreExecCommandStatus) -> Self { - match value { - CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, - CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, - CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, - } - } -} - -v2_enum_from_core! { - #[derive(Default)] - pub enum CommandExecutionSource from CoreExecCommandSource { - #[default] - Agent, - UserShell, - UnifiedExecStartup, - UnifiedExecInteraction, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentTool { - SpawnAgent, - SendInput, - ResumeAgent, - Wait, - CloseAgent, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileUpdateChange { - pub path: String, - pub kind: PatchChangeKind, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PatchChangeKind { - Add, - Delete, - Update { move_path: Option }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum PatchApplyStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for PatchApplyStatus { - fn from(value: CorePatchApplyStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CorePatchApplyStatus> for PatchApplyStatus { - fn from(value: &CorePatchApplyStatus) -> Self { - match value { - CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, - CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, - CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, - } - } -} - -impl From for McpToolCallStatus { - fn from(value: CoreMcpToolCallStatus) -> Self { - match value { - CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, - CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, - CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentStatus { - PendingInit, - Running, - Interrupted, - Completed, - Errored, - Shutdown, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollabAgentState { - pub status: CollabAgentStatus, - pub message: Option, -} - -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallResult { - // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust - // representation of MCP content blocks. We intentionally use `serde_json::Value` here because - // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types - // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust - // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. - pub content: Vec, - pub structured_content: Option, - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallError { - pub message: String, -} - -// === Server Notifications === -// Thread/Turn lifecycle notifications and item progress events -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartedNotification { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStatusChangedNotification { - pub thread_id: String, - pub status: ThreadStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadClosedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification emitted when watched local skill files change. -/// -/// Treat this as an invalidation signal and re-run `skills/list` with the -/// client's current parameters when refreshed skill metadata is needed. -pub struct SkillsChangedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadNameUpdatedNotification { - pub thread_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalUpdatedNotification { - pub thread_id: String, - pub turn_id: Option, - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookStartedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Usage { - pub input_tokens: i32, - pub cached_input_tokens: i32, - pub output_tokens: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnCompletedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookCompletedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification that the turn-level unified diff has changed. -/// Contains the latest aggregated diff across all file changes in the turn. -pub struct TurnDiffUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub explanation: Option, - pub plan: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanStep { - pub step: String, - pub status: TurnPlanStepStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnPlanStepStatus { - Pending, - InProgress, - Completed, -} - -impl From for TurnPlanStep { - fn from(value: CorePlanItemArg) -> Self { - Self { - step: value.step, - status: value.status.into(), - } - } -} - -impl From for TurnPlanStepStatus { - fn from(value: CorePlanStepStatus) -> Self { - match value { - CorePlanStepStatus::Pending => Self::Pending, - CorePlanStepStatus::InProgress => Self::InProgress, - CorePlanStepStatus::Completed => Self::Completed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemStartedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, - /// Unix timestamp (in milliseconds) when this item lifecycle started. - #[ts(type = "number")] - pub started_at_ms: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewStartedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewCompletedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub decision_source: AutoReviewDecisionSource, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemCompletedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, - /// Unix timestamp (in milliseconds) when this item lifecycle completed. - #[ts(type = "number")] - pub completed_at_ms: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RawResponseItemCompletedNotification { - pub thread_id: String, - pub turn_id: String, - pub item: ResponseItem, -} - -// Item-specific progress notifications -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AgentMessageDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should -/// not assume concatenated deltas match the completed plan item content. -pub struct PlanDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryPartAddedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub content_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TerminalInteractionNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub process_id: String, - pub stdin: String, -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -/// Base64-encoded output chunk emitted for a streaming `command/exec` request. -/// -/// These notifications are connection-scoped. If the originating connection -/// closes, the server terminates the process. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecOutputDeltaNotification { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Output stream for this chunk. - pub stream: CommandExecOutputStream, - /// Base64-encoded output bytes. - pub delta_base64: String, - /// `true` on the final streamed chunk for a stream when `outputBytesCap` - /// truncated later output on that stream. - pub cap_reached: bool, -} - -/// Deprecated legacy notification for `apply_patch` textual output. -/// -/// The server no longer emits this notification. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangePatchUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub changes: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ServerRequestResolvedNotification { - pub thread_id: String, - pub request_id: RequestId, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallProgressNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginCompletedNotification { - pub name: String, - pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerStartupState { - Starting, - Ready, - Failed, - Cancelled, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatusUpdatedNotification { - pub name: String, - pub status: McpServerStartupState, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsWorldWritableWarningNotification { - pub sample_paths: Vec, - pub extra_count: usize, - pub failed_scan: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WindowsSandboxSetupMode { - Elevated, - Unelevated, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WindowsSandboxReadiness { - Ready, - NotConfigured, - UpdateRequired, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartParams { - pub mode: WindowsSandboxSetupMode, - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartResponse { - pub started: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxReadinessResponse { - pub status: WindowsSandboxReadiness, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupCompletedNotification { - pub mode: WindowsSandboxSetupMode, - pub success: bool, - pub error: Option, -} - -/// Deprecated: Use `ContextCompaction` item type instead. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ContextCompactedNotification { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Unique identifier for this specific approval callback. - /// - /// For regular shell/unified_exec approvals, this is null. - /// - /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to - /// one parent `itemId`, so `approvalId` is a distinct opaque callback id - /// (a UUID) used to disambiguate routing. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub approval_id: Option, - /// Optional explanatory reason (e.g. request for network access). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub reason: Option, - /// Optional context for a managed-network approval prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub network_approval_context: Option, - /// The command to be executed. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command: Option, - /// The command's working directory. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub cwd: Option, - /// Best-effort parsed command actions for friendly display. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command_actions: Option>, - /// Optional additional permissions requested for this command. - #[experimental("item/commandExecution/requestApproval.additionalPermissions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub additional_permissions: Option, - /// Optional proposed execpolicy amendment to allow similar commands without prompting. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_execpolicy_amendment: Option, - /// Optional proposed network policy amendments (allow/deny host) for future requests. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_network_policy_amendments: Option>, - /// Ordered list of decisions the client may present for this prompt. - #[experimental("item/commandExecution/requestApproval.availableDecisions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub available_decisions: Option>, -} - -impl CommandExecutionRequestApprovalParams { - pub fn strip_experimental_fields(&mut self) { - // TODO: Avoid hardcoding individual experimental fields here. - // We need a generic outbound compatibility design for stripping or - // otherwise handling experimental server->client payloads. - self.additional_permissions = None; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalResponse { - pub decision: CommandExecutionApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Optional explanatory reason (e.g. request for extra write access). - #[ts(optional = nullable)] - pub reason: Option, - /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root - /// for the remainder of the session (unclear if this is honored today). - #[ts(optional = nullable)] - pub grant_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalResponse { - pub decision: FileChangeApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationAction { - Accept, - Decline, - Cancel, -} - -impl McpServerElicitationAction { - pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { - match self { - Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, - Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, - Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, - } - } -} - -impl From for rmcp::model::ElicitationAction { - fn from(value: McpServerElicitationAction) -> Self { - match value { - McpServerElicitationAction::Accept => Self::Accept, - McpServerElicitationAction::Decline => Self::Decline, - McpServerElicitationAction::Cancel => Self::Cancel, - } - } -} - -impl From for McpServerElicitationAction { - fn from(value: rmcp::model::ElicitationAction) -> Self { - match value { - rmcp::model::ElicitationAction::Accept => Self::Accept, - rmcp::model::ElicitationAction::Decline => Self::Decline, - rmcp::model::ElicitationAction::Cancel => Self::Cancel, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestParams { - pub thread_id: String, - /// Active Codex turn when this elicitation was observed, if app-server could correlate one. - /// - /// This is nullable because MCP models elicitation as a standalone server-to-client request - /// identified by the MCP server request id. It may be triggered during a turn, but turn - /// context is app-server correlation rather than part of the protocol identity of the - /// elicitation itself. - pub turn_id: Option, - pub server_name: String, - #[serde(flatten)] - pub request: McpServerElicitationRequest, - // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated - // McpToolCall item id here as an optional field. The current core event does not carry that - // association. -} - -/// Typed form schema for MCP `elicitation/create` requests. -/// -/// This matches the `requestedSchema` shape from the MCP 2025-11-25 -/// `ElicitRequestFormParams` schema. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationSchema { - #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "$schema")] - pub schema_uri: Option, - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationObjectType, - pub properties: BTreeMap, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub required: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationObjectType { - Object, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationPrimitiveSchema { - Enum(McpElicitationEnumSchema), - String(McpElicitationStringSchema), - Number(McpElicitationNumberSchema), - Boolean(McpElicitationBooleanSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationStringSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationStringType { - String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum McpElicitationStringFormat { - Email, - Uri, - Date, - DateTime, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationNumberSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationNumberType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub minimum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub maximum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationNumberType { - Number, - Integer, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationBooleanSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationBooleanType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationBooleanType { - Boolean, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationEnumSchema { - SingleSelect(McpElicitationSingleSelectEnumSchema), - MultiSelect(McpElicitationMultiSelectEnumSchema), - Legacy(McpElicitationLegacyTitledEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationLegacyTitledEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "enumNames")] - pub enum_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationSingleSelectEnumSchema { - Untitled(McpElicitationUntitledSingleSelectEnumSchema), - Titled(McpElicitationTitledSingleSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "oneOf")] - #[ts(rename = "oneOf")] - pub one_of: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationMultiSelectEnumSchema { - Untitled(McpElicitationUntitledMultiSelectEnumSchema), - Titled(McpElicitationTitledMultiSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationUntitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationTitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationArrayType { - Array, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledEnumItems { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledEnumItems { - #[serde(rename = "anyOf", alias = "oneOf")] - #[ts(rename = "anyOf")] - pub any_of: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationConstOption { - #[serde(rename = "const")] - #[ts(rename = "const")] - pub const_: String, - pub title: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "mode", rename_all = "camelCase")] -#[ts(tag = "mode")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationRequest { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Form { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - requested_schema: McpElicitationSchema, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Url { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - url: String, - elicitation_id: String, - }, -} - -impl TryFrom for McpServerElicitationRequest { - type Error = serde_json::Error; - - fn try_from(value: CoreElicitationRequest) -> Result { - match value { - CoreElicitationRequest::Form { - meta, - message, - requested_schema, - } => Ok(Self::Form { - meta, - message, - requested_schema: serde_json::from_value(requested_schema)?, - }), - CoreElicitationRequest::Url { - meta, - message, - url, - elicitation_id, - } => Ok(Self::Url { - meta, - message, - url, - elicitation_id, - }), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestResponse { - pub action: McpServerElicitationAction, - /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. - /// - /// This is nullable because decline/cancel responses have no content. - pub content: Option, - /// Optional client metadata for form-mode action handling. - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -impl From for rmcp::model::CreateElicitationResult { - fn from(value: McpServerElicitationRequestResponse) -> Self { - Self { - action: value.action.into(), - content: value.content, - } - } -} - -impl From for McpServerElicitationRequestResponse { - fn from(value: rmcp::model::CreateElicitationResult) -> Self { - Self { - action: value.action.into(), - content: value.content, - meta: None, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallParams { - pub thread_id: String, - pub turn_id: String, - pub call_id: String, - pub namespace: Option, - pub tool: String, - pub arguments: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub cwd: AbsolutePathBuf, - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -v2_enum_from_core!( - #[derive(Default)] - pub enum PermissionGrantScope from CorePermissionGrantScope { - #[default] - Turn, - Session - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalResponse { - pub permissions: GrantedPermissionProfile, - #[serde(default)] - pub scope: PermissionGrantScope, - /// Review every subsequent command in this turn before normal sandboxed execution. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub strict_auto_review: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallResponse { - pub content_items: Vec, - pub success: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallOutputContentItem { - #[serde(rename_all = "camelCase")] - InputText { text: String }, - #[serde(rename_all = "camelCase")] - InputImage { image_url: String }, -} - -impl From - for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem -{ - fn from(item: DynamicToolCallOutputContentItem) -> Self { - match item { - DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, - DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Defines a single selectable option for request_user_input. -pub struct ToolRequestUserInputOption { - pub label: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Represents one request_user_input question and its required options. -pub struct ToolRequestUserInputQuestion { - pub id: String, - pub header: String, - pub question: String, - #[serde(default)] - pub is_other: bool, - #[serde(default)] - pub is_secret: bool, - pub options: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Params sent with a request_user_input event. -pub struct ToolRequestUserInputParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub questions: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. -pub struct ToolRequestUserInputAnswer { - pub answers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Response payload mapping question ids to answers. -pub struct ToolRequestUserInputResponse { - pub answers: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountRateLimitsUpdatedNotification { - pub rate_limits: RateLimitSnapshot, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitSnapshot { - pub limit_id: Option, - pub limit_name: Option, - pub primary: Option, - pub secondary: Option, - pub credits: Option, - pub plan_type: Option, - pub rate_limit_reached_type: Option, -} - -impl From for RateLimitSnapshot { - fn from(value: CoreRateLimitSnapshot) -> Self { - Self { - limit_id: value.limit_id, - limit_name: value.limit_name, - primary: value.primary.map(RateLimitWindow::from), - secondary: value.secondary.map(RateLimitWindow::from), - credits: value.credits.map(CreditsSnapshot::from), - plan_type: value.plan_type, - rate_limit_reached_type: value - .rate_limit_reached_type - .map(RateLimitReachedType::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum RateLimitReachedType { - RateLimitReached, - WorkspaceOwnerCreditsDepleted, - WorkspaceMemberCreditsDepleted, - WorkspaceOwnerUsageLimitReached, - WorkspaceMemberUsageLimitReached, -} - -impl From for RateLimitReachedType { - fn from(value: CoreRateLimitReachedType) -> Self { - match value { - CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, - CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -impl From for CoreRateLimitReachedType { - fn from(value: RateLimitReachedType) -> Self { - match value { - RateLimitReachedType::RateLimitReached => Self::RateLimitReached, - RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - RateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitWindow { - pub used_percent: i32, - #[ts(type = "number | null")] - pub window_duration_mins: Option, - #[ts(type = "number | null")] - pub resets_at: Option, -} - -impl From for RateLimitWindow { - fn from(value: CoreRateLimitWindow) -> Self { - Self { - used_percent: value.used_percent.round() as i32, - window_duration_mins: value.window_minutes, - resets_at: value.resets_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CreditsSnapshot { - pub has_credits: bool, - pub unlimited: bool, - pub balance: Option, -} - -impl From for CreditsSnapshot { - fn from(value: CoreCreditsSnapshot) -> Self { - Self { - has_credits: value.has_credits, - unlimited: value.unlimited, - balance: value.balance, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountLoginCompletedNotification { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - pub login_id: Option, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelReroutedNotification { - pub thread_id: String, - pub turn_id: String, - pub from_model: String, - pub to_model: String, - pub reason: ModelRerouteReason, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelVerificationNotification { - pub thread_id: String, - pub turn_id: String, - pub verifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeprecationNoticeNotification { - /// Concise summary of what is deprecated. - pub summary: String, - /// Optional extra guidance, such as migration steps or rationale. - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WarningNotification { - /// Optional thread target when the warning applies to a specific thread. - pub thread_id: Option, - /// Concise warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianWarningNotification { - /// Thread target for the guardian warning. - pub thread_id: String, - /// Concise guardian warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextPosition { - /// 1-based line number. - pub line: usize, - /// 1-based column number (in Unicode scalar values). - pub column: usize, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextRange { - pub start: TextPosition, - pub end: TextPosition, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWarningNotification { - /// Concise summary of the warning. - pub summary: String, - /// Optional extra guidance or error details. - pub details: Option, - /// Optional path to the config file that triggered the warning. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub path: Option, - /// Optional range for the error location inside the config file. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub range: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::items::AgentMessageContent; - use codex_protocol::items::AgentMessageItem; - use codex_protocol::items::FileChangeItem; - use codex_protocol::items::ImageViewItem; - use codex_protocol::items::McpToolCallItem; - use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; - use codex_protocol::items::ReasoningItem; - use codex_protocol::items::TurnItem; - use codex_protocol::items::UserMessageItem; - use codex_protocol::items::WebSearchItem; - use codex_protocol::mcp::CallToolResult; - use codex_protocol::models::WebSearchAction as CoreWebSearchAction; - use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use codex_protocol::user_input::UserInput as CoreUserInput; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::num::NonZeroUsize; - use std::path::PathBuf; - use std::time::Duration; - - fn absolute_path_string(path: &str) -> String { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).display().to_string() - } - - fn absolute_path(path: &str) -> AbsolutePathBuf { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).abs() - } - - fn test_absolute_path() -> AbsolutePathBuf { - absolute_path("readable") - } - - #[test] - fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), - "\"user\"" - ); - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" - ); - - for value in ["user", "auto_review", "guardian_subagent"] { - let json = format!("\"{value}\""); - let reviewer: ApprovalsReviewer = - serde_json::from_str(&json).expect("deserialize reviewer"); - let expected = if value == "user" { - ApprovalsReviewer::User - } else { - ApprovalsReviewer::AutoReview - }; - assert_eq!(expected, reviewer); - } - } - - #[test] - fn turn_defaults_legacy_missing_items_view_to_full() { - let turn: Turn = serde_json::from_value(json!({ - "id": "turn_123", - "items": [], - "status": "completed", - "error": null, - "startedAt": null, - "completedAt": null, - "durationMs": null, - })) - .expect("legacy turn should deserialize"); - - assert_eq!(turn.items_view, TurnItemsView::Full); - } - - #[test] - fn thread_list_params_accepts_single_cwd() { - let params = serde_json::from_value::(json!({ - "cwd": "/workspace", - })) - .expect("single cwd should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One("/workspace".to_string())) - ); - assert!(!params.use_state_db_only); - } - - #[test] - fn thread_list_params_accepts_multiple_cwds() { - let params = serde_json::from_value::(json!({ - "cwd": ["/workspace", "/other-workspace"], - })) - .expect("cwd array should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::Many(vec![ - "/workspace".to_string(), - "/other-workspace".to_string(), - ])) - ); - } - - #[test] - fn thread_list_params_accepts_state_db_only_flag() { - let params = serde_json::from_value::(json!({ - "useStateDbOnly": true, - })) - .expect("state db only flag should deserialize"); - - assert!(params.use_state_db_only); - } - - #[test] - fn collab_agent_state_maps_interrupted_status() { - assert_eq!( - CollabAgentState::from(CoreAgentStatus::Interrupted), - CollabAgentState { - status: CollabAgentStatus::Interrupted, - message: None, - } - ); - } - - #[test] - fn external_agent_config_plugins_details_round_trip() { - let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - })) - .expect("plugins migration item should deserialize"); - - assert_eq!( - item, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - } - ); - } - - #[test] - fn external_agent_config_import_params_accept_legacy_plugin_details() { - let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ - "migrationItems": [{ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - }] - })) - .expect("legacy plugin import params should deserialize"); - - assert_eq!( - params, - ExternalAgentConfigImportParams { - migration_items: vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - }], - } - ); - } - - #[test] - fn command_execution_request_approval_rejects_relative_additional_permission_paths() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": absolute_path_string("tmp"), - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": { - "read": ["relative/path"], - "write": null - } - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect_err("relative additional permission paths should fail"); - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } - - #[test] - fn permissions_request_approval_uses_request_permission_profile() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions request should deserialize"); - - assert_eq!(params.cwd, absolute_path("repo")); - assert_eq!( - params.permissions, - RequestPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreRequestPermissionProfile::from(params.permissions), - CoreRequestPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_rejects_macos_permissions() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": "none", - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": false, - "contacts": "none", - }, - }, - })) - .expect_err("permissions request should reject macos permissions"); - - assert!( - err.to_string().contains("unknown field `macos`"), - "unexpected error: {err}" - ); - } - - #[test] - fn additional_file_system_permissions_preserves_canonical_entries() { - let core_permissions = CoreFileSystemPermissions { - entries: vec![ - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::Special { - value: CoreFileSystemSpecialPath::Root, - }, - access: CoreFileSystemAccessMode::Write, - }, - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }, - ], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: None, - write: None, - glob_scan_max_depth: NonZeroUsize::new(2), - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_populates_entries_for_legacy_roots() { - let read_only_path = absolute_path("read-only"); - let read_write_path = absolute_path("read-write"); - let core_permissions = CoreFileSystemPermissions::from_read_write_roots( - Some(vec![read_only_path.clone()]), - Some(vec![read_write_path.clone()]), - ); - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: Some(vec![read_only_path.clone()]), - write: Some(vec![read_write_path.clone()]), - glob_scan_max_depth: None, - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_only_path, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_write_path, - }, - access: FileSystemAccessMode::Write, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "read": null, - "write": null, - "globScanMaxDepth": 0, - "entries": [], - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { - let special_path = serde_json::from_value::(json!({ - "kind": "current_working_directory", - })) - .expect("legacy cwd special path should deserialize"); - - assert_eq!( - special_path, - FileSystemSpecialPath::ProjectRoots { subpath: None } - ); - assert_eq!( - serde_json::to_value(&special_path).expect("serialize special path"), - json!({ - "kind": "project_roots", - "subpath": null, - }) - ); - } - - #[test] - fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let response = serde_json::from_value::(json!({ - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions response should deserialize"); - - assert_eq!( - response.permissions, - GrantedPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreAdditionalPermissionProfile::from(response.permissions), - CoreAdditionalPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_response_defaults_scope_to_turn() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - })) - .expect("response should deserialize"); - - assert_eq!(response.scope, PermissionGrantScope::Turn); - assert_eq!(response.strict_auto_review, None); - } - - #[test] - fn permissions_request_approval_response_accepts_strict_auto_review() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - "strictAutoReview": true, - })) - .expect("response should deserialize"); - - assert_eq!(response.strict_auto_review, Some(true)); - } - - #[test] - fn fs_get_metadata_response_round_trips_minimal_fields() { - let response = FsGetMetadataResponse { - is_directory: false, - is_file: true, - is_symlink: false, - created_at_ms: 123, - modified_at_ms: 456, - }; - - let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); - assert_eq!( - value, - json!({ - "isDirectory": false, - "isFile": true, - "isSymlink": false, - "createdAtMs": 123, - "modifiedAtMs": 456, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/getMetadata response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_response_round_trips_base64_data() { - let response = FsReadFileResponse { - data_base64: "aGVsbG8=".to_string(), - }; - - let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); - assert_eq!( - value, - json!({ - "dataBase64": "aGVsbG8=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_params_round_trip() { - let params = FsReadFileParams { - path: absolute_path("tmp/example.txt"), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.txt"), - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_create_params_round_trip_uses_protection_policy() { - let params = DeviceKeyCreateParams { - protection_policy: None, - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/create params"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create params"); - assert_eq!(decoded, params); - - let params = DeviceKeyCreateParams { - protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/create params with protection policy"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": "allow_os_protected_nonextractable", - }) - ); - } - - #[test] - fn device_key_create_response_round_trips_protection_class() { - let response = DeviceKeyCreateResponse { - key_id: "dk_123".to_string(), - public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/create response"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE", - "algorithm": "ecdsa_p256_sha256", - "protectionClass": "os_protected_nonextractable", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create response"); - assert_eq!(decoded, response); - } - - #[test] - fn device_key_sign_params_round_trip_uses_accepted_payload_enum() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientConnection { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, - session_id: "wssess_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/api/codex/remote/control/client".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), - token_expires_at: 1_700_000_000, - scopes: vec!["remote_control_controller_websocket".to_string()], - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientConnection", - "nonce": "nonce-1", - "audience": "remote_control_client_websocket", - "sessionId": "wssess_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/api/codex/remote/control/client", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "tokenExpiresAt": 1_700_000_000, - "scopes": ["remote_control_controller_websocket"], - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_params_round_trip_uses_enrollment_payload() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientEnrollment { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, - challenge_id: "rch_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/wham/remote/control/client/enroll".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" - .to_string(), - challenge_expires_at: 1_700_000_000, - }, - }; - - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/sign params with enrollment payload"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientEnrollment", - "nonce": "nonce-1", - "audience": "remote_control_client_enrollment", - "challengeId": "rch_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/wham/remote/control/client/enroll", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "challengeExpiresAt": 1_700_000_000, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params with enrollment payload"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_response_returns_signed_payload_bytes() { - let response = DeviceKeySignResponse { - signature_der_base64: "MEUCIQD".to_string(), - signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/sign response"); - assert_eq!( - value, - json!({ - "signatureDerBase64": "MEUCIQD", - "signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA", - "algorithm": "ecdsa_p256_sha256", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_create_directory_params_round_trip_with_default_recursive() { - let params = FsCreateDirectoryParams { - path: absolute_path("tmp/example"), - recursive: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example"), - "recursive": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/createDirectory params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_write_file_params_round_trip_with_base64_data() { - let params = FsWriteFileParams { - path: absolute_path("tmp/example.bin"), - data_base64: "AAE=".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.bin"), - "dataBase64": "AAE=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/writeFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_copy_params_round_trip_with_recursive_directory_copy() { - let params = FsCopyParams { - source_path: absolute_path("tmp/source"), - destination_path: absolute_path("tmp/destination"), - recursive: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); - assert_eq!( - value, - json!({ - "sourcePath": absolute_path_string("tmp/source"), - "destinationPath": absolute_path_string("tmp/destination"), - "recursive": true, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize fs/copy params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_params_round_trip() { - let params = ThreadShellCommandParams { - thread_id: "thr_123".to_string(), - command: "printf 'hello world\\n'".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); - assert_eq!( - value, - json!({ - "threadId": "thr_123", - "command": "printf 'hello world\\n'", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_response_round_trip() { - let response = ThreadShellCommandResponse {}; - - let value = - serde_json::to_value(&response).expect("serialize thread/shellCommand response"); - assert_eq!(value, json!({})); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_changed_notification_round_trips() { - let notification = FsChangedNotification { - watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), - changed_paths: vec![ - absolute_path("tmp/repo/.git/HEAD"), - absolute_path("tmp/repo/.git/FETCH_HEAD"), - ], - }; - - let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); - assert_eq!( - value, - json!({ - "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", - "changedPaths": [ - absolute_path_string("tmp/repo/.git/HEAD"), - absolute_path_string("tmp/repo/.git/FETCH_HEAD"), - ], - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/changed notification"); - assert_eq!(decoded, notification); - } - - #[test] - fn command_exec_params_default_optional_streaming_flags() { - let params = serde_json::from_value::(json!({ - "command": ["ls", "-la"], - "timeoutMs": 1000, - "cwd": "/tmp" - })) - .expect("command/exec payload should deserialize"); - - assert_eq!( - params, - CommandExecParams { - command: vec!["ls".to_string(), "-la".to_string()], - process_id: None, - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: Some(1000), - cwd: Some(PathBuf::from("/tmp")), - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - } - ); - } - - #[test] - fn command_exec_params_round_trips_disable_timeout() { - let params = CommandExecParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_id: Some("sleep-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: true, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["sleep", "30"], - "processId": "sleep-1", - "disableTimeout": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - "outputBytesCap": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn process_spawn_params_round_trips_without_sandbox_policy() { - let params = ProcessSpawnParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_handle: "sleep-1".to_string(), - cwd: test_absolute_path(), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - timeout_ms: None, - env: None, - size: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); - assert_eq!( - value, - json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "env": null, - "size": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn process_spawn_params_distinguish_omitted_null_and_value_limits() { - let base = json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - }); - - let expected_omitted = ProcessSpawnParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_handle: "sleep-1".to_string(), - cwd: test_absolute_path(), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - timeout_ms: None, - env: None, - size: None, - }; - let decoded = - serde_json::from_value::(base).expect("deserialize omitted limits"); - assert_eq!(decoded, expected_omitted); - - let decoded = serde_json::from_value::(json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "outputBytesCap": null, - "timeoutMs": null, - })) - .expect("deserialize disabled limits"); - assert_eq!( - decoded, - ProcessSpawnParams { - output_bytes_cap: Some(None), - timeout_ms: Some(None), - ..expected_omitted.clone() - } - ); - - let decoded = serde_json::from_value::(json!({ - "command": ["sleep", "30"], - "processHandle": "sleep-1", - "cwd": absolute_path_string("readable"), - "outputBytesCap": 123, - "timeoutMs": 456, - })) - .expect("deserialize explicit limits"); - assert_eq!( - decoded, - ProcessSpawnParams { - output_bytes_cap: Some(Some(123)), - timeout_ms: Some(Some(456)), - ..expected_omitted - } - ); - } - - #[test] - fn command_exec_params_round_trips_disable_output_cap() { - let params = CommandExecParams { - command: vec!["yes".to_string()], - process_id: Some("yes-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: true, - output_bytes_cap: None, - disable_output_cap: true, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["yes"], - "processId": "yes-1", - "streamStdoutStderr": true, - "outputBytesCap": null, - "disableOutputCap": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trips_env_overrides_and_unsets() { - let params = CommandExecParams { - command: vec!["printenv".to_string(), "FOO".to_string()], - process_id: Some("env-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: Some(HashMap::from([ - ("FOO".to_string(), Some("override".to_string())), - ("BAR".to_string(), Some("added".to_string())), - ("BAZ".to_string(), None), - ])), - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["printenv", "FOO"], - "processId": "env-1", - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": { - "FOO": "override", - "BAR": "added", - "BAZ": null, - }, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_write_round_trips_close_only_payload() { - let params = CommandExecWriteParams { - process_id: "proc-7".to_string(), - delta_base64: None, - close_stdin: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); - assert_eq!( - value, - json!({ - "processId": "proc-7", - "deltaBase64": null, - "closeStdin": true, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_terminate_round_trips() { - let params = CommandExecTerminateParams { - process_id: "proc-8".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); - assert_eq!( - value, - json!({ - "processId": "proc-8", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trip_with_size() { - let params = CommandExecParams { - command: vec!["top".to_string()], - process_id: Some("pty-1".to_string()), - tty: true, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: Some(CommandExecTerminalSize { - rows: 40, - cols: 120, - }), - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["top"], - "processId": "pty-1", - "tty": true, - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": { - "rows": 40, - "cols": 120, - }, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_resize_round_trips() { - let params = CommandExecResizeParams { - process_id: "proc-9".to_string(), - size: CommandExecTerminalSize { - rows: 50, - cols: 160, - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); - assert_eq!( - value, - json!({ - "processId": "proc-9", - "size": { - "rows": 50, - "cols": 160, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_output_delta_round_trips() { - let notification = CommandExecOutputDeltaNotification { - process_id: "proc-1".to_string(), - stream: CommandExecOutputStream::Stdout, - delta_base64: "AQI=".to_string(), - cap_reached: false, - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize command/exec/outputDelta notification"); - assert_eq!( - value, - json!({ - "processId": "proc-1", - "stream": "stdout", - "deltaBase64": "AQI=", - "capReached": false, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn process_control_params_round_trip() { - let write = ProcessWriteStdinParams { - process_handle: "proc-7".to_string(), - delta_base64: None, - close_stdin: true, - }; - let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - "deltaBase64": null, - "closeStdin": true, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/writeStdin params"); - assert_eq!(decoded, write); - - let resize = ProcessResizePtyParams { - process_handle: "proc-7".to_string(), - size: ProcessTerminalSize { - rows: 50, - cols: 160, - }, - }; - let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - "size": { - "rows": 50, - "cols": 160, - }, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/resizePty params"); - assert_eq!(decoded, resize); - - let kill = ProcessKillParams { - process_handle: "proc-7".to_string(), - }; - let value = serde_json::to_value(&kill).expect("serialize process/kill params"); - assert_eq!( - value, - json!({ - "processHandle": "proc-7", - }) - ); - let decoded = - serde_json::from_value::(value).expect("deserialize process/kill"); - assert_eq!(decoded, kill); - } - - #[test] - fn process_notifications_round_trip() { - let delta = ProcessOutputDeltaNotification { - process_handle: "proc-1".to_string(), - stream: ProcessOutputStream::Stdout, - delta_base64: "AQI=".to_string(), - cap_reached: false, - }; - let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); - assert_eq!( - value, - json!({ - "processHandle": "proc-1", - "stream": "stdout", - "deltaBase64": "AQI=", - "capReached": false, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/outputDelta"); - assert_eq!(decoded, delta); - - let exited = ProcessExitedNotification { - process_handle: "proc-1".to_string(), - exit_code: 0, - stdout: "out".to_string(), - stdout_cap_reached: false, - stderr: "err".to_string(), - stderr_cap_reached: true, - }; - let value = serde_json::to_value(&exited).expect("serialize process/exited"); - assert_eq!( - value, - json!({ - "processHandle": "proc-1", - "exitCode": 0, - "stdout": "out", - "stdoutCapReached": false, - "stderr": "err", - "stderrCapReached": true, - }) - ); - let decoded = serde_json::from_value::(value) - .expect("deserialize process/exited"); - assert_eq!(decoded, exited); - } - - #[test] - fn command_execution_output_delta_round_trips() { - let notification = CommandExecutionOutputDeltaNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - item_id: "item-1".to_string(), - delta: "\u{fffd}a\n".to_string(), - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize item/commandExecution/outputDelta notification"); - assert_eq!( - value, - json!({ - "threadId": "thread-1", - "turnId": "turn-1", - "itemId": "item-1", - "delta": "\u{fffd}a\n", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn sandbox_policy_round_trips_external_sandbox_network_access() { - let v2_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: CoreNetworkAccess::Enabled, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_round_trips_read_only_network_access() { - let v2_policy = SandboxPolicy::ReadOnly { - network_access: true, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: true, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_round_trips_request_permissions_flag() { - let v2_policy = AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }) - ); - - let back_to_v2 = AskForApproval::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { - let decoded = serde_json::from_value::(serde_json::json!({ - "granular": { - "sandbox_approval": true, - "rules": false, - "mcp_elicitations": true, - } - })) - .expect("granular approval policy should deserialize"); - - assert_eq!( - decoded, - AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - } - ); - } - - #[test] - fn ask_for_approval_granular_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::OnRequest, - ), - None - ); - } - - #[test] - fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { - let reason = - crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { - allowed_approval_policies: Some(vec![AskForApproval::Granular { - sandbox_approval: true, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_web_search_modes: None, - feature_requirements: None, - hooks: None, - enforce_residency: None, - network: None, - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadStart { - request_id: crate::RequestId::Integer(1), - params: ThreadStartParams { - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadResume { - request_id: crate::RequestId::Integer(2), - params: ThreadResumeParams { - thread_id: "thr_123".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadFork { - request_id: crate::RequestId::Integer(3), - params: ThreadForkParams { - thread_id: "thr_456".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::TurnStart { - request_id: crate::RequestId::Integer(4), - params: TurnStartParams { - thread_id: "thr_123".to_string(), - input: Vec::new(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn mcp_server_elicitation_response_round_trips_rmcp_result() { - let rmcp_result = rmcp::model::CreateElicitationResult { - action: rmcp::model::ElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - }; - - let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); - assert_eq!( - v2_response, - McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - } - ); - assert_eq!( - rmcp::model::CreateElicitationResult::from(v2_response), - rmcp_result - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_url_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - }) - .expect("URL request should convert"); - - assert_eq!( - request, - McpServerElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_form_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - }), - }) - .expect("form request should convert"); - - let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - })) - .expect("expected schema should deserialize"); - - assert_eq!( - request, - McpServerElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: expected_schema, - } - ); - } - - #[test] - fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { - let schema: McpElicitationSchema = serde_json::from_value(json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "email": { - "type": "string", - "title": "Email", - "description": "Work email address", - "format": "email", - "default": "dev@example.com", - }, - "count": { - "type": "integer", - "title": "Count", - "description": "How many items to create", - "minimum": 1, - "maximum": 5, - "default": 3, - }, - "confirmed": { - "type": "boolean", - "title": "Confirm", - "description": "Approve the pending action", - "default": true, - }, - "legacyChoice": { - "type": "string", - "title": "Action", - "description": "Legacy titled enum form", - "enum": ["allow", "deny"], - "enumNames": ["Allow", "Deny"], - "default": "allow", - }, - }, - "required": ["email", "confirmed"], - })) - .expect("schema should deserialize"); - - assert_eq!( - schema, - McpElicitationSchema { - schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), - type_: McpElicitationObjectType::Object, - properties: BTreeMap::from([ - ( - "confirmed".to_string(), - McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { - type_: McpElicitationBooleanType::Boolean, - title: Some("Confirm".to_string()), - description: Some("Approve the pending action".to_string()), - default: Some(true), - }), - ), - ( - "count".to_string(), - McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { - type_: McpElicitationNumberType::Integer, - title: Some("Count".to_string()), - description: Some("How many items to create".to_string()), - minimum: Some(1.0), - maximum: Some(5.0), - default: Some(3.0), - }), - ), - ( - "email".to_string(), - McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { - type_: McpElicitationStringType::String, - title: Some("Email".to_string()), - description: Some("Work email address".to_string()), - min_length: None, - max_length: None, - format: Some(McpElicitationStringFormat::Email), - default: Some("dev@example.com".to_string()), - }), - ), - ( - "legacyChoice".to_string(), - McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( - McpElicitationLegacyTitledEnumSchema { - type_: McpElicitationStringType::String, - title: Some("Action".to_string()), - description: Some("Legacy titled enum form".to_string()), - enum_: vec!["allow".to_string(), "deny".to_string()], - enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), - default: Some("allow".to_string()), - }, - )), - ), - ]), - required: Some(vec!["email".to_string(), "confirmed".to_string()]), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_rejects_null_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: Some(json!({ - "persist": "session", - })), - message: "Allow this request?".to_string(), - requested_schema: JsonValue::Null, - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "object", - } - }, - }), - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_response_serializes_nullable_content() { - let response = McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Decline, - content: None, - meta: None, - }; - - assert_eq!( - serde_json::to_value(response).expect("response should serialize"), - json!({ - "action": "decline", - "content": null, - "_meta": null, - }) - ); - } - - #[test] - fn sandbox_policy_round_trips_workspace_write_access() { - let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { - let policy = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "fullAccess" - }, - "networkAccess": true - })) - .expect("read-only policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::ReadOnly { - network_access: true - } - ); - } - - #[test] - fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { - let writable_root = absolute_path("/workspace"); - let policy = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [writable_root], - "readOnlyAccess": { - "type": "fullAccess" - }, - "networkAccess": true, - "excludeTmpdirEnvVar": true, - "excludeSlashTmp": true - })) - .expect("workspace-write policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], - network_access: true, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } - ); - } - - #[test] - fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { - let err = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - } - })) - .expect_err("read-only policy should reject removed restricted access field"); - assert!(err.to_string().contains("readOnly.access")); - } - - #[test] - fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { - let err = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [], - "readOnlyAccess": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - }, - "networkAccess": false, - "excludeTmpdirEnvVar": false, - "excludeSlashTmp": false - })) - .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); - assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); - } - - #[test] - fn automatic_approval_review_deserializes_aborted_status() { - let review: GuardianApprovalReview = serde_json::from_value(json!({ - "status": "aborted", - "riskLevel": null, - "userAuthorization": null, - "rationale": null - })) - .expect("aborted automatic review should deserialize"); - assert_eq!( - review, - GuardianApprovalReview { - status: GuardianApprovalReviewStatus::Aborted, - risk_level: None, - user_authorization: None, - rationale: None, - } - ); - } - - #[test] - fn guardian_approval_review_action_round_trips_command_shape() { - let value = json!({ - "type": "command", - "source": "shell", - "command": "rm -rf /tmp/example.sqlite", - "cwd": absolute_path_string("tmp"), - }); - let action: GuardianApprovalReviewAction = - serde_json::from_value(value.clone()).expect("guardian review action"); - - assert_eq!( - action, - GuardianApprovalReviewAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf /tmp/example.sqlite".to_string(), - cwd: absolute_path("tmp"), - } - ); - assert_eq!( - serde_json::to_value(&action).expect("serialize guardian review action"), - value - ); - } - - #[test] - fn network_requirements_deserializes_legacy_fields() { - let requirements: NetworkRequirements = serde_json::from_value(json!({ - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "allowUnixSockets": ["/tmp/proxy.sock"] - })) - .expect("legacy network requirements should deserialize"); - - assert_eq!( - requirements, - NetworkRequirements { - enabled: None, - http_port: None, - socks_port: None, - allow_upstream_proxy: None, - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - domains: None, - managed_allowed_domains_only: None, - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: None, - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: None, - } - ); - } - - #[test] - fn network_requirements_serializes_canonical_and_legacy_fields() { - let requirements = NetworkRequirements { - enabled: Some(true), - http_port: Some(8080), - socks_port: Some(1080), - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: Some(false), - dangerously_allow_all_unix_sockets: Some(true), - domains: Some(BTreeMap::from([ - ("api.openai.com".to_string(), NetworkDomainPermission::Allow), - ( - "blocked.example.com".to_string(), - NetworkDomainPermission::Deny, - ), - ])), - managed_allowed_domains_only: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: Some(BTreeMap::from([ - ( - "/tmp/proxy.sock".to_string(), - NetworkUnixSocketPermission::Allow, - ), - ( - "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermission::None, - ), - ])), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: Some(true), - }; - - assert_eq!( - serde_json::to_value(requirements).expect("network requirements should serialize"), - json!({ - "enabled": true, - "httpPort": 8080, - "socksPort": 1080, - "allowUpstreamProxy": false, - "dangerouslyAllowNonLoopbackProxy": false, - "dangerouslyAllowAllUnixSockets": true, - "domains": { - "api.openai.com": "allow", - "blocked.example.com": "deny" - }, - "managedAllowedDomainsOnly": true, - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "unixSockets": { - "/tmp/ignored.sock": "none", - "/tmp/proxy.sock": "allow" - }, - "allowUnixSockets": ["/tmp/proxy.sock"], - "allowLocalBinding": true - }) - ); - } - - #[test] - fn core_turn_item_into_thread_item_converts_supported_variants() { - let user_item = TurnItem::UserMessage(UserMessageItem { - id: "user-1".to_string(), - content: vec![ - CoreUserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - CoreUserInput::Image { - image_url: "https://example.com/image.png".to_string(), - }, - CoreUserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - CoreUserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - CoreUserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - }); - - assert_eq!( - ThreadItem::from(user_item), - ThreadItem::UserMessage { - id: "user-1".to_string(), - content: vec![ - UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - UserInput::Image { - url: "https://example.com/image.png".to_string(), - }, - UserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - UserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - UserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - } - ); - - let agent_item = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-1".to_string(), - content: vec![ - AgentMessageContent::Text { - text: "Hello ".to_string(), - }, - AgentMessageContent::Text { - text: "world".to_string(), - }, - ], - phase: None, - memory_citation: None, - }); - - assert_eq!( - ThreadItem::from(agent_item), - ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "Hello world".to_string(), - phase: None, - memory_citation: None, - } - ); - - let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-2".to_string(), - content: vec![AgentMessageContent::Text { - text: "final".to_string(), - }], - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(CoreMemoryCitation { - entries: vec![CoreMemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - rollout_ids: vec!["rollout-1".to_string()], - }), - }); - - assert_eq!( - ThreadItem::from(agent_item_with_phase), - ThreadItem::AgentMessage { - id: "agent-2".to_string(), - text: "final".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(MemoryCitation { - entries: vec![MemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - thread_ids: vec!["rollout-1".to_string()], - }), - } - ); - - let reasoning_item = TurnItem::Reasoning(ReasoningItem { - id: "reasoning-1".to_string(), - summary_text: vec!["line one".to_string(), "line two".to_string()], - raw_content: vec![], - }); - - assert_eq!( - ThreadItem::from(reasoning_item), - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["line one".to_string(), "line two".to_string()], - content: vec![], - } - ); - - let search_item = TurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "docs".to_string(), - action: CoreWebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }, - }); - - assert_eq!( - ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } - ); - - let image_view_item = TurnItem::ImageView(ImageViewItem { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - }); - - assert_eq!( - ThreadItem::from(image_view_item), - ThreadItem::ImageView { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - } - ); - - let file_change_item = TurnItem::FileChange(FileChangeItem { - id: "patch-1".to_string(), - changes: [( - PathBuf::from("README.md"), - codex_protocol::protocol::FileChange::Add { - content: "hello\n".to_string(), - }, - )] - .into_iter() - .collect(), - status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), - auto_approved: None, - stdout: Some("Done!".to_string()), - stderr: Some(String::new()), - }); - - assert_eq!( - ThreadItem::from(file_change_item), - ThreadItem::FileChange { - id: "patch-1".to_string(), - changes: vec![FileUpdateChange { - path: "README.md".to_string(), - kind: PatchChangeKind::Add, - diff: "hello\n".to_string(), - }], - status: PatchApplyStatus::Completed, - } - ); - - let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { - id: "mcp-1".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - arguments: json!({"arg": "value"}), - mcp_app_resource_uri: Some("app://connector".to_string()), - status: CoreMcpToolCallStatus::InProgress, - result: None, - error: None, - duration: None, - }); - - assert_eq!( - ThreadItem::from(mcp_tool_call_item), - ThreadItem::McpToolCall { - id: "mcp-1".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - status: McpToolCallStatus::InProgress, - arguments: json!({"arg": "value"}), - mcp_app_resource_uri: Some("app://connector".to_string()), - result: None, - error: None, - duration_ms: None, - } - ); - - let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { - id: "mcp-2".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - status: CoreMcpToolCallStatus::Completed, - result: Some(CallToolResult { - content: vec![json!({"type": "text", "text": "ok"})], - structured_content: Some(json!({"ok": true})), - is_error: Some(false), - meta: Some(json!({"trace": "1"})), - }), - error: None, - duration: Some(Duration::from_millis(42)), - }); - - assert_eq!( - ThreadItem::from(completed_mcp_tool_call_item), - ThreadItem::McpToolCall { - id: "mcp-2".to_string(), - server: "server".to_string(), - tool: "tool".to_string(), - status: McpToolCallStatus::Completed, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: Some(Box::new(McpToolCallResult { - content: vec![json!({"type": "text", "text": "ok"})], - structured_content: Some(json!({"ok": true})), - meta: Some(json!({"trace": "1"})), - })), - error: None, - duration_ms: Some(42), - } - ); - } - - #[test] - fn skills_list_params_serialization_uses_force_reload() { - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: Vec::new(), - force_reload: false, - per_cwd_extra_user_roots: None, - }) - .unwrap(), - json!({ - "perCwdExtraUserRoots": null, - }), - ); - - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: vec![PathBuf::from("/repo")], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: PathBuf::from("/repo"), - extra_user_roots: vec![ - PathBuf::from("/shared/skills"), - PathBuf::from("/tmp/x") - ], - }]), - }) - .unwrap(), - json!({ - "cwds": ["/repo"], - "forceReload": true, - "perCwdExtraUserRoots": [ - { - "cwd": "/repo", - "extraUserRoots": ["/shared/skills", "/tmp/x"], - } - ], - }), - ); - } - - #[test] - fn plugin_source_serializes_local_git_and_remote_variants() { - let local_path = if cfg!(windows) { - r"C:\plugins\linear" - } else { - "/plugins/linear" - }; - let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); - let local_path_json = local_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), - json!({ - "type": "local", - "path": local_path_json, - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Git { - url: "https://github.com/openai/example.git".to_string(), - path: Some("plugins/example".to_string()), - ref_name: Some("main".to_string()), - sha: Some("abc123".to_string()), - }) - .unwrap(), - json!({ - "type": "git", - "url": "https://github.com/openai/example.git", - "path": "plugins/example", - "refName": "main", - "sha": "abc123", - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Remote).unwrap(), - json!({ - "type": "remote", - }), - ); - } - - #[test] - fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: None, - sparse_paths: None, - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": null, - "sparsePaths": null, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: Some("main".to_string()), - sparse_paths: Some(vec!["plugins/foo".to_string()]), - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": "main", - "sparsePaths": ["plugins/foo"], - }), - ); - } - - #[test] - fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: None, - }) - .unwrap(), - json!({ - "marketplaceName": null, - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - MarketplaceUpgradeParams { - marketplace_name: None, - }, - ); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: Some("debug".to_string()), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - }), - ); - } - - #[test] - fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { - assert_eq!( - serde_json::to_value(PluginMarketplaceEntry { - name: "openai-curated".to_string(), - path: None, - interface: None, - plugins: Vec::new(), - }) - .unwrap(), - json!({ - "name": "openai-curated", - "path": null, - "interface": null, - "plugins": [], - }), - ); - } - - #[test] - fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { - let composer_icon = if cfg!(windows) { - r"C:\plugins\linear\icon.png" - } else { - "/plugins/linear/icon.png" - }; - let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); - let composer_icon_json = composer_icon.as_path().display().to_string(); - - let interface = PluginInterface { - display_name: Some("Linear".to_string()), - short_description: None, - long_description: None, - developer_name: None, - category: Some("Productivity".to_string()), - capabilities: Vec::new(), - website_url: None, - privacy_policy_url: None, - terms_of_service_url: None, - default_prompt: None, - brand_color: None, - composer_icon: Some(composer_icon), - composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), - logo: None, - logo_url: Some("https://example.com/linear/logo.png".to_string()), - screenshots: Vec::new(), - screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], - }; - - assert_eq!( - serde_json::to_value(interface).unwrap(), - json!({ - "displayName": "Linear", - "shortDescription": null, - "longDescription": null, - "developerName": null, - "category": "Productivity", - "capabilities": [], - "websiteUrl": null, - "privacyPolicyUrl": null, - "termsOfServiceUrl": null, - "defaultPrompt": null, - "brandColor": null, - "composerIcon": composer_icon_json, - "composerIconUrl": "https://example.com/linear/icon.png", - "logo": null, - "logoUrl": "https://example.com/linear/logo.png", - "screenshots": [], - "screenshotUrls": ["https://example.com/linear/screenshot.png"], - }), - ); - } - - #[test] - fn plugin_list_params_ignore_removed_force_remote_sync_field() { - assert_eq!( - serde_json::from_value::(json!({ - "cwds": null, - "forceRemoteSync": true, - })) - .unwrap(), - PluginListParams { cwds: None }, - ); - } - - #[test] - fn plugin_read_params_serialization_uses_install_source_fields() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginReadParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - })) - .unwrap(), - PluginReadParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_install_params_serialization_omits_force_remote_sync() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginInstallParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { - assert_eq!( - serde_json::to_value(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - skill_name: "plan-work".to_string(), - }) - .unwrap(), - json!({ - "remoteMarketplaceName": "chatgpt-global", - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "skillName": "plan-work", - }), - ); - } - - #[test] - fn plugin_share_params_and_response_serialization_use_camel_case_fields() { - let plugin_path = if cfg!(windows) { - r"C:\plugins\gmail" - } else { - "/plugins/gmail" - }; - let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); - let plugin_path_json = plugin_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path: plugin_path.clone(), - remote_plugin_id: None, - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": null, - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path, - remote_plugin_id: Some( - "plugins~Plugin_00000000000000000000000000000000".to_string(), - ), - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveResponse { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - share_url: String::new(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "shareUrl": "", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - PluginShareListParams {}, - ); - - assert_eq!( - serde_json::to_value(PluginShareDeleteParams { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - } - - #[test] - fn plugin_share_list_response_serializes_share_items() { - assert_eq!( - serde_json::to_value(PluginShareListResponse { - data: vec![PluginShareListItem { - plugin: PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - name: "gmail".to_string(), - source: PluginSource::Remote, - installed: false, - enabled: false, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: None, - }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), - local_plugin_path: None, - }], - }) - .unwrap(), - json!({ - "data": [{ - "plugin": { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "availability": "AVAILABLE", - "interface": null, - }, - "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", - "localPluginPath": null, - }], - }), - ); - } - - #[test] - fn plugin_summary_defaults_missing_availability_to_available() { - let summary: PluginSummary = serde_json::from_value(json!({ - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "interface": null, - })) - .unwrap(); - - assert_eq!(summary.availability, PluginAvailability::Available); - } - - #[test] - fn plugin_availability_deserializes_enabled_alias() { - let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); - - assert_eq!(availability, PluginAvailability::Available); - assert_eq!( - serde_json::to_value(availability).unwrap(), - json!("AVAILABLE") - ); - } - - #[test] - fn plugin_uninstall_params_serialization_omits_force_remote_sync() { - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "gmail@openai-curated", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "gmail@openai-curated", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }, - ); - - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "plugins~Plugin_gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "plugins~Plugin_gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }, - ); - } - - #[test] - fn marketplace_remove_response_serializes_nullable_installed_root() { - let installed_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); - let installed_root_json = installed_root.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: Some(installed_root), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": installed_root_json, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: None, - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": null, - }), - ); - } - - #[test] - fn marketplace_upgrade_response_serializes_camel_case_fields() { - let upgraded_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); - let upgraded_root_json = upgraded_root.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeResponse { - selected_marketplaces: vec!["debug".to_string()], - upgraded_roots: vec![upgraded_root], - errors: vec![MarketplaceUpgradeErrorInfo { - marketplace_name: "broken".to_string(), - message: "failed to clone".to_string(), - }], - }) - .unwrap(), - json!({ - "selectedMarketplaces": ["debug"], - "upgradedRoots": [upgraded_root_json], - "errors": [{ - "marketplaceName": "broken", - "message": "failed to clone", - }], - }), - ); - } - - #[test] - fn codex_error_info_serializes_http_status_code_in_camel_case() { - let value = CodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(401), - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "responseTooManyFailedAttempts": { - "httpStatusCode": 401 - } - }) - ); - } - - #[test] - fn codex_error_info_serializes_cyber_policy_in_camel_case() { - assert_eq!( - serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), - json!("cyberPolicy") - ); - } - - #[test] - fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { - let value = CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "activeTurnNotSteerable": { - "turnKind": "review" - } - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_text_and_image_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }, - ], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - }, - { - "type": "inputImage", - "imageUrl": "data:image/png;base64,AAA" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_spec_deserializes_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "string" } - } - }, - "deferLoading": true, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert_eq!( - actual, - DynamicToolSpec { - namespace: None, - name: "lookup_ticket".to_string(), - description: "Fetch a ticket".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "id": { "type": "string" } - } - }), - defer_loading: true, - } - ); - } - - #[test] - fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": {} - }, - "exposeToContext": false, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert!(actual.defer_loading); - } - - #[test] - fn thread_start_params_preserve_explicit_null_service_tier() { - let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let serialized_without_override = - serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn thread_lifecycle_responses_default_missing_compat_fields() { - let response = json!({ - "thread": { - "id": "thread-id", - "forkedFromId": null, - "preview": "", - "ephemeral": false, - "modelProvider": "openai", - "createdAt": 1, - "updatedAt": 1, - "status": { "type": "idle" }, - "path": null, - "cwd": absolute_path_string("tmp"), - "cliVersion": "0.0.0", - "source": "exec", - "agentNickname": null, - "agentRole": null, - "gitInfo": null, - "name": null, - "turns": [] - }, - "model": "gpt-5", - "modelProvider": "openai", - "serviceTier": null, - "cwd": absolute_path_string("tmp"), - "approvalPolicy": "on-failure", - "approvalsReviewer": "user", - "sandbox": { "type": "dangerFullAccess" }, - "reasoningEffort": null - }); - - let start: ThreadStartResponse = - serde_json::from_value(response.clone()).expect("thread/start response"); - let resume: ThreadResumeResponse = - serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = - serde_json::from_value(response).expect("thread/fork response"); - - assert_eq!(start.instruction_sources, Vec::::new()); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); - assert_eq!(start.active_permission_profile, None); - assert_eq!(resume.active_permission_profile, None); - assert_eq!(fork.active_permission_profile, None); - } - - #[test] - fn turn_start_params_preserve_explicit_null_service_tier() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "serviceTier": null - })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let without_override = TurnStartParams { - thread_id: "thread_123".to_string(), - input: vec![], - responsesapi_client_metadata: None, - environments: None, - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permissions: None, - model: None, - service_tier: None, - effort: None, - summary: None, - output_schema: None, - collaboration_mode: None, - personality: None, - }; - let serialized_without_override = - serde_json::to_value(&without_override).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": cwd - } - ], - })) - .expect("params should deserialize"); - - assert_eq!( - params.environments, - Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd: cwd.clone(), - }]) - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("environments"), - Some(&json!([ - { - "environmentId": "local", - "cwd": cwd - } - ])) - ); - } - - #[test] - fn turn_start_params_preserve_empty_environments() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [], - })) - .expect("params should deserialize"); - - assert_eq!(params.environments, Some(Vec::new())); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!(serialized.get("environments"), Some(&json!([]))); - } - - #[test] - fn turn_start_params_treat_null_or_omitted_environments_as_default() { - let null_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": null, - })) - .expect("params should deserialize"); - let omitted_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - })) - .expect("params should deserialize"); - - assert_eq!(null_environments.environments, None); - assert_eq!(omitted_environments.environments, None); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), - None - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), - None - ); - } - - #[test] - fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } -} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs new file mode 100644 index 0000000000..efb4a26f60 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -0,0 +1,383 @@ +use crate::protocol::common::AuthMode; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::account::PlanType; +use codex_protocol::account::ProviderAccount; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum Account { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "amazonBedrock", rename_all = "camelCase")] + #[ts(rename = "amazonBedrock", rename_all = "camelCase")] + AmazonBedrock {}, +} + +impl From for Account { + fn from(account: ProviderAccount) -> Self { + match account { + ProviderAccount::ApiKey => Self::ApiKey {}, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(tag = "type")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountParams { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey { + #[serde(rename = "apiKey")] + #[ts(rename = "apiKey")] + api_key: String, + }, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. + #[experimental("account/login/start.chatgptAuthTokens")] + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens { + /// Access token (JWT) supplied by the client. + /// This token is used for backend API requests and email extraction. + access_token: String, + /// Workspace/account identifier supplied by the client. + chatgpt_account_id: String, + /// Optional plan type supplied by the client. + /// + /// When `null`, Codex attempts to derive the plan type from access-token + /// claims. If unavailable, the plan defaults to `unknown`. + #[ts(optional = nullable)] + chatgpt_plan_type: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CancelLoginAccountStatus { + Canceled, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse { + pub status: CancelLoginAccountStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct LogoutAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ChatgptAuthTokensRefreshReason { + /// Codex attempted a backend request and received `401 Unauthorized`. + Unauthorized, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshParams { + pub reason: ChatgptAuthTokensRefreshReason, + /// Workspace/account identifier that Codex was previously using. + /// + /// Clients that manage multiple accounts/workspaces can use this as a hint + /// to refresh the token for the correct workspace. + /// + /// This may be `null` when the prior auth state did not include a workspace + /// identifier (`chatgpt_account_id`). + #[ts(optional = nullable)] + pub previous_account_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshResponse { + pub access_token: String, + pub chatgpt_account_id: String, + pub chatgpt_plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsResponse { + /// Backward-compatible single-bucket view; mirrors the historical payload. + pub rate_limits: RateLimitSnapshot, + /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). + pub rate_limits_by_limit_id: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + /// When `true`, requests a proactive token refresh before returning. + /// + /// In managed auth mode this triggers the normal refresh-token flow. In + /// external auth mode this flag is ignored. Clients should refresh tokens + /// themselves and call `account/login/start` with `chatgptAuthTokens`. + #[serde(default)] + pub refresh_token: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountResponse { + pub account: Option, + pub requires_openai_auth: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountUpdatedNotification { + pub auth_mode: Option, + pub plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub limit_id: Option, + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, + pub credits: Option, + pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + limit_id: value.limit_id, + limit_name: value.limit_name, + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + credits: value.credits.map(CreditsSnapshot::from), + plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + #[ts(type = "number | null")] + pub window_duration_mins: Option, + #[ts(type = "number | null")] + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + +impl From for CreditsSnapshot { + fn from(value: CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs new file mode 100644 index 0000000000..9f46525e6c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -0,0 +1,146 @@ +use super::shared::default_enabled; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - list available apps/connectors. +pub struct AppsListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional thread id used to evaluate app feature gating from that thread's config. + #[ts(optional = nullable)] + pub thread_id: Option, + /// When true, bypass app caches and fetch the latest data from sources. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_refetch: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppInfo { + pub id: String, + pub name: String, + pub description: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, + #[serde(default)] + pub is_accessible: bool, + /// Whether this app is enabled in config.toml. + /// Example: + /// ```toml + /// [apps.bad_app] + /// enabled = false + /// ``` + #[serde(default = "default_enabled")] + pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata summary for plugin responses. +pub struct AppSummary { + pub id: String, + pub name: String, + pub description: Option, + pub install_url: Option, + pub needs_auth: bool, +} + +impl From for AppSummary { + fn from(value: AppInfo) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + install_url: value.install_url, + needs_auth: false, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app list response. +pub struct AppsListResponse { + pub data: Vec, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - notification emitted when the app list changes. +pub struct AppListUpdatedNotification { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs new file mode 100644 index 0000000000..b013bc13d4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs @@ -0,0 +1,45 @@ +use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode preset metadata for clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + #[serde(rename = "reasoning_effort")] + #[ts(rename = "reasoning_effort")] + pub reasoning_effort: Option>, +} + +impl From for CollaborationModeMask { + fn from(value: CoreCollaborationModeMask) -> Self { + Self { + name: value.name, + mode: value.mode, + model: value.model, + reasoning_effort: value.reasoning_effort, + } + } +} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs new file mode 100644 index 0000000000..ff0cecf4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -0,0 +1,214 @@ +use super::PermissionProfile; +use super::SandboxPolicy; +use codex_experimental_api_macros::ExperimentalApi; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +/// PTY size in character cells for `command/exec` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. + #[ts(optional = nullable)] + pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. + #[ts(optional = nullable)] + pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("command/exec.permissionProfile")] + #[ts(optional = nullable)] + pub permission_profile: Option, +} + +/// Final buffered result for `command/exec`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResponse { + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. + pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. + pub stderr: String, +} + +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `command/exec/write`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +/// Terminate a running `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, +} + +/// Empty success response for `command/exec/terminate`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +/// Resize a running PTY-backed `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// New PTY size in character cells. + pub size: CommandExecTerminalSize, +} + +/// Empty success response for `command/exec/resize`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +/// Stream label for `command/exec/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Output stream for this chunk. + pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. + pub cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs new file mode 100644 index 0000000000..1c1ef35d62 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -0,0 +1,703 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::SandboxMode; +use super::shared::default_enabled; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ConfigLayerSource { + /// Managed preferences layer delivered by MDM (macOS only). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Mdm { + domain: String, + key: String, + }, + + /// Managed config layer from a file (usually `managed_config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + System { + /// This is the path to the system config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// User config layer from $CODEX_HOME/config.toml. This layer is special + /// in that it is expected to be: + /// - writable by the user + /// - generally outside the workspace directory + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + User { + /// This is the path to the user's config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// Path to a .codex/ folder within a project. There could be multiple of + /// these between `cwd` and the project/repo root. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Project { + dot_codex_folder: AbsolutePathBuf, + }, + + /// Session-layer overrides supplied via `-c`/`--config`. + SessionFlags, + + /// `managed_config.toml` was designed to be a config that was loaded + /// as the last layer on top of everything else. This scheme did not quite + /// work out as intended, but we keep this variant as a "best effort" while + /// we phase out `managed_config.toml` in favor of `requirements.toml`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, + + LegacyManagedConfigTomlFromMdm, +} + +impl ConfigLayerSource { + /// A settings from a layer with a higher precedence will override a setting + /// from a layer with a lower precedence. + pub fn precedence(&self) -> i16 { + match self { + ConfigLayerSource::Mdm { .. } => 0, + ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::Project { .. } => 25, + ConfigLayerSource::SessionFlags => 30, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, + } + } +} + +/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from +/// layer `A` will be overridden by settings from layer `B`. +impl PartialOrd for ConfigLayerSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.precedence().cmp(&other.precedence())) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional profile-level override for where approval requests + /// are routed for review. If omitted, the enclosing config default is + /// used. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub service_tier: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub web_search: Option, + pub tools: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AnalyticsConfig { + pub enabled: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, + #[serde(default, flatten)] + pub apps: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional default for where approval requests are routed for + /// review. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub web_search: Option, + pub tools: Option, + pub profile: Option, + #[experimental(nested)] + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub service_tier: Option, + pub analytics: Option, + #[experimental("config/read.apps")] + #[serde(default)] + pub apps: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayerMetadata { + pub name: ConfigLayerSource, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayer { + pub name: ConfigLayerSource, + pub version: String, + pub config: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum MergeStrategy { + Replace, + Upsert, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WriteStatus { + Ok, + OkOverridden, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct OverriddenMetadata { + pub message: String, + pub overriding_layer: ConfigLayerMetadata, + pub effective_value: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWriteResponse { + pub status: WriteStatus, + pub version: String, + /// Canonical path to the config file that was written. + pub file_path: AbsolutePathBuf, + pub overridden_metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigWriteErrorCode { + ConfigLayerReadonly, + ConfigVersionConflict, + ConfigValidationError, + ConfigPathNotFound, + ConfigSchemaUnknownKey, + UserLayerNotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadParams { + #[serde(default)] + pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadResponse { + #[experimental(nested)] + pub config: Config, + pub origins: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub layers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirements { + #[experimental(nested)] + pub allowed_approval_policies: Option>, + #[experimental("configRequirements/read.allowedApprovalsReviewers")] + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, + pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, + pub enforce_residency: Option, + #[experimental("configRequirements/read.network")] + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ConfiguredHookHandler { + #[serde(rename = "command")] + #[ts(rename = "command")] + Command { + command: String, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + #[ts(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + #[ts(rename = "agent")] + Agent {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkRequirements { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. + pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. + pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ResidencyRequirement { + Us, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirementsReadResponse { + /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). + #[experimental(nested)] + pub requirements: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum ExternalAgentConfigMigrationItemType { + #[serde(rename = "AGENTS_MD")] + #[ts(rename = "AGENTS_MD")] + AgentsMd, + #[serde(rename = "CONFIG")] + #[ts(rename = "CONFIG")] + Config, + #[serde(rename = "SKILLS")] + #[ts(rename = "SKILLS")] + Skills, + #[serde(rename = "PLUGINS")] + #[ts(rename = "PLUGINS")] + Plugins, + #[serde(rename = "MCP_SERVER_CONFIG")] + #[ts(rename = "MCP_SERVER_CONFIG")] + McpServerConfig, + #[serde(rename = "SUBAGENTS")] + #[ts(rename = "SUBAGENTS")] + Subagents, + #[serde(rename = "HOOKS")] + #[ts(rename = "HOOKS")] + Hooks, + #[serde(rename = "COMMANDS")] + #[ts(rename = "COMMANDS")] + Commands, + #[serde(rename = "SESSIONS")] + #[ts(rename = "SESSIONS")] + Sessions, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginsMigration { + #[serde(rename = "marketplaceName")] + #[ts(rename = "marketplaceName")] + pub marketplace_name: String, + #[serde(rename = "pluginNames")] + #[ts(rename = "pluginNames")] + pub plugin_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SessionMigration { + pub path: PathBuf, + pub cwd: PathBuf, + pub title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SubagentMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MigrationDetails { + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub subagents: Vec, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigMigrationItem { + pub item_type: ExternalAgentConfigMigrationItemType, + pub description: String, + /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. + pub cwd: Option, + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectResponse { + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectParams { + /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_home: bool, + /// Zero or more working directories to include for repo-scoped detection. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportParams { + pub migration_items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportCompletedNotification {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigValueWriteParams { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigBatchWriteParams { + pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, + /// When true, hot-reload the updated user config into all loaded threads after writing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reload_user_config: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigEdit { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWarningNotification { + /// Concise summary of the warning. + pub summary: String, + /// Optional extra guidance or error details. + pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs b/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs new file mode 100644 index 0000000000..3330996c1c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/device_key.rs @@ -0,0 +1,181 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Device-key algorithm reported at enrollment and signing boundaries. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyAlgorithm { + EcdsaP256Sha256, +} + +/// Platform protection class for a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyProtectionClass { + HardwareSecureEnclave, + HardwareTpm, + OsProtectedNonextractable, +} + +/// Protection policy for creating or loading a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum DeviceKeyProtectionPolicy { + HardwareOnly, + AllowOsProtectedNonextractable, +} + +/// Create a controller-local device key with a random key id. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyCreateParams { + /// Defaults to `hardware_only` when omitted. + #[ts(optional = nullable)] + pub protection_policy: Option, + pub account_user_id: String, + pub client_id: String, +} + +/// Device-key metadata and public key returned by create/public APIs. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyCreateResponse { + pub key_id: String, + /// SubjectPublicKeyInfo DER encoded as base64. + pub public_key_spki_der_base64: String, + pub algorithm: DeviceKeyAlgorithm, + pub protection_class: DeviceKeyProtectionClass, +} + +/// Fetch a controller-local device key public key by id. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyPublicParams { + pub key_id: String, +} + +/// Device-key public metadata returned by `device/key/public`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeyPublicResponse { + pub key_id: String, + /// SubjectPublicKeyInfo DER encoded as base64. + pub public_key_spki_der_base64: String, + pub algorithm: DeviceKeyAlgorithm, + pub protection_class: DeviceKeyProtectionClass, +} + +/// Current remote-control connection status and environment id exposed to clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusChangedNotification { + pub status: RemoteControlConnectionStatus, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlConnectionStatus { + Disabled, + Connecting, + Connected, + Errored, +} + +/// Audience for a remote-control client connection device-key proof. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum RemoteControlClientConnectionAudience { + RemoteControlClientWebsocket, +} + +/// Audience for a remote-control client enrollment device-key proof. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum RemoteControlClientEnrollmentAudience { + RemoteControlClientEnrollment, +} + +/// Structured payloads accepted by `device/key/sign`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum DeviceKeySignPayload { + /// Payload bound to one remote-control controller websocket `/client` connection challenge. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RemoteControlClientConnection { + nonce: String, + audience: RemoteControlClientConnectionAudience, + /// Backend-issued websocket session id that this proof authorizes. + session_id: String, + /// Origin of the backend endpoint that issued the challenge and will verify this proof. + target_origin: String, + /// Websocket route path that this proof authorizes. + target_path: String, + account_user_id: String, + client_id: String, + /// Remote-control token expiration as Unix seconds. + #[ts(type = "number")] + token_expires_at: i64, + /// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. + token_sha256_base64url: String, + /// Must contain exactly `remote_control_controller_websocket`. + scopes: Vec, + }, + /// Payload bound to a remote-control client `/client/enroll` ownership challenge. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RemoteControlClientEnrollment { + nonce: String, + audience: RemoteControlClientEnrollmentAudience, + /// Backend-issued enrollment challenge id that this proof authorizes. + challenge_id: String, + /// Origin of the backend endpoint that issued the challenge and will verify this proof. + target_origin: String, + /// HTTP route path that this proof authorizes. + target_path: String, + account_user_id: String, + client_id: String, + /// SHA-256 of the requested device identity operation, encoded as unpadded base64url. + device_identity_sha256_base64url: String, + /// Enrollment challenge expiration as Unix seconds. + #[ts(type = "number")] + challenge_expires_at: i64, + }, +} + +/// Sign an accepted structured payload with a controller-local device key. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeySignParams { + pub key_id: String, + pub payload: DeviceKeySignPayload, +} + +/// ASN.1 DER signature returned by `device/key/sign`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeviceKeySignResponse { + /// ECDSA signature DER encoded as base64. + pub signature_der_base64: String, + /// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte + /// string directly and must not reserialize `payload`. + pub signed_payload_base64: String, + pub algorithm: DeviceKeyAlgorithm, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs new file mode 100644 index 0000000000..6adc21b6ef --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -0,0 +1,85 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExperimentalFeatureStage { + /// Feature is available for user testing and feedback. + Beta, + /// Feature is still being built and not ready for broad use. + UnderDevelopment, + /// Feature is production-ready. + Stable, + /// Feature is deprecated and should be avoided. + Deprecated, + /// Feature flag is retained only for backwards compatibility. + Removed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeature { + /// Stable key used in config.toml and CLI flag toggles. + pub name: String, + /// Lifecycle stage of this feature flag. + pub stage: ExperimentalFeatureStage, + /// User-facing display name shown in the experimental features UI. + /// Null when this feature is not in beta. + pub display_name: Option, + /// Short summary describing what the feature does. + /// Null when this feature is not in beta. + pub description: Option, + /// Announcement copy shown to users when the feature is introduced. + /// Null when this feature is not in beta. + pub announcement: Option, + /// Whether this feature is currently enabled in the loaded config. + pub enabled: bool, + /// Whether this feature is enabled by default. + pub default_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListResponse { + pub data: Vec, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: BTreeMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs new file mode 100644 index 0000000000..aaf966a4bf --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -0,0 +1,29 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { + pub classification: String, + #[ts(optional = nullable)] + pub reason: Option, + #[ts(optional = nullable)] + pub thread_id: Option, + pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/fs.rs b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs new file mode 100644 index 0000000000..0132c6b284 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Read a file from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileParams { + /// Absolute path to read. + pub path: AbsolutePathBuf, +} + +/// Base64-encoded file contents returned by `fs/readFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileResponse { + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Write a file on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileParams { + /// Absolute path to write. + pub path: AbsolutePathBuf, + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Successful response for `fs/writeFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileResponse {} + +/// Create a directory on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryParams { + /// Absolute directory path to create. + pub path: AbsolutePathBuf, + /// Whether parent directories should also be created. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, +} + +/// Successful response for `fs/createDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryResponse {} + +/// Request metadata for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataParams { + /// Absolute path to inspect. + pub path: AbsolutePathBuf, +} + +/// Metadata returned by `fs/getMetadata`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataResponse { + /// Whether the path resolves to a directory. + pub is_directory: bool, + /// Whether the path resolves to a regular file. + pub is_file: bool, + /// Whether the path itself is a symbolic link. + pub is_symlink: bool, + /// File creation time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub created_at_ms: i64, + /// File modification time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub modified_at_ms: i64, +} + +/// List direct child names for a directory. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryParams { + /// Absolute directory path to read. + pub path: AbsolutePathBuf, +} + +/// A directory entry returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryEntry { + /// Direct child entry name only, not an absolute or relative path. + pub file_name: String, + /// Whether this entry resolves to a directory. + pub is_directory: bool, + /// Whether this entry resolves to a regular file. + pub is_file: bool, +} + +/// Directory entries returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryResponse { + /// Direct child entries in the requested directory. + pub entries: Vec, +} + +/// Remove a file or directory tree from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveParams { + /// Absolute path to remove. + pub path: AbsolutePathBuf, + /// Whether directory removal should recurse. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, + /// Whether missing paths should be ignored. Defaults to `true`. + #[ts(optional = nullable)] + pub force: Option, +} + +/// Successful response for `fs/remove`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveResponse {} + +/// Copy a file or directory tree on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyParams { + /// Absolute source path. + pub source_path: AbsolutePathBuf, + /// Absolute destination path. + pub destination_path: AbsolutePathBuf, + /// Required for directory copies; ignored for file copies. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, +} + +/// Successful response for `fs/copy`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyResponse {} + +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Successful response for `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs new file mode 100644 index 0000000000..e0f74b5ded --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -0,0 +1,154 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::protocol::HookEventName as CoreHookEventName; +use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; +use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; +use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; +use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; +use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::HookSource as CoreHookSource; +use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum HookEventName from CoreHookEventName { + PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop + } +); + +v2_enum_from_core!( + pub enum HookHandlerType from CoreHookHandlerType { + Command, Prompt, Agent + } +); + +v2_enum_from_core!( + pub enum HookExecutionMode from CoreHookExecutionMode { + Sync, Async + } +); + +v2_enum_from_core!( + pub enum HookScope from CoreHookScope { + Thread, Turn + } +); + +v2_enum_from_core!( + pub enum HookSource from CoreHookSource { + System, + User, + Project, + Mdm, + SessionFlags, + Plugin, + CloudRequirements, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + Unknown, + } +); + +v2_enum_from_core!( + pub enum HookTrustStatus from CoreHookTrustStatus { + Managed, Untrusted, Trusted, Modified + } +); + +fn default_hook_source() -> HookSource { + HookSource::Unknown +} + +v2_enum_from_core!( + pub enum HookRunStatus from CoreHookRunStatus { + Running, Completed, Failed, Blocked, Stopped + } +); + +v2_enum_from_core!( + pub enum HookOutputEntryKind from CoreHookOutputEntryKind { + Warning, Stop, Feedback, Context, Error + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookOutputEntry { + pub kind: HookOutputEntryKind, + pub text: String, +} + +impl From for HookOutputEntry { + fn from(value: CoreHookOutputEntry) -> Self { + Self { + kind: value.kind.into(), + text: value.text, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookRunSummary { + pub id: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub execution_mode: HookExecutionMode, + pub scope: HookScope, + pub source_path: AbsolutePathBuf, + #[serde(default = "default_hook_source")] + pub source: HookSource, + pub display_order: i64, + pub status: HookRunStatus, + pub status_message: Option, + pub started_at: i64, + pub completed_at: Option, + pub duration_ms: Option, + pub entries: Vec, +} + +impl From for HookRunSummary { + fn from(value: CoreHookRunSummary) -> Self { + Self { + id: value.id, + event_name: value.event_name.into(), + handler_type: value.handler_type.into(), + execution_mode: value.execution_mode.into(), + scope: value.scope.into(), + source_path: value.source_path, + source: value.source.into(), + display_order: value.display_order, + status: value.status.into(), + status_message: value.status_message, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, + entries: value.entries.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookStartedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookCompletedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs new file mode 100644 index 0000000000..2c3a926c91 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -0,0 +1,1432 @@ +use super::AdditionalPermissionProfile; +use super::ExecPolicyAmendment; +use super::McpToolCallError; +use super::McpToolCallResult; +use super::NetworkApprovalContext; +use super::NetworkApprovalProtocol; +use super::NetworkPolicyAmendment; +use super::RequestPermissionProfile; +use super::UserInput; +use super::shared::v2_enum_from_core; +use crate::protocol::item_builders::convert_patch_changes; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; +use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; +use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use serde_with::serde_as; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionApprovalDecision { + /// User approved the command. + Accept, + /// User approved the command and future prompts in the same session-scoped + /// approval cache should run without prompting. + AcceptForSession, + /// User approved the command, and wants to apply the proposed execpolicy amendment so future + /// matching commands can run without prompting. + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, + /// User chose a persistent network policy rule (allow/deny) for this host. + ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + /// User denied the command. The agent will continue the turn. + Decline, + /// User denied the command. The turn will also be immediately interrupted. + Cancel, +} + +impl From for CommandExecutionApprovalDecision { + fn from(value: CoreReviewDecision) -> Self { + match value { + CoreReviewDecision::Approved => Self::Accept, + CoreReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => Self::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into(), + }, + CoreReviewDecision::Abort => Self::Cancel, + CoreReviewDecision::Denied => Self::Decline, + CoreReviewDecision::TimedOut => Self::Decline, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum FileChangeApprovalDecision { + /// User approved the file changes. + Accept, + /// User approved the file changes and future changes to the same files should run without prompting. + AcceptForSession, + /// User denied the file changes. The agent will continue the turn. + Decline, + /// User denied the file changes. The turn will also be immediately interrupted. + Cancel, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum CommandAction { + Read { + command: String, + name: String, + path: AbsolutePathBuf, + }, + ListFiles { + command: String, + path: Option, + }, + Search { + command: String, + query: Option, + path: Option, + }, + Unknown { + command: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitation { + pub entries: Vec, + pub thread_ids: Vec, +} + +impl From for MemoryCitation { + fn from(value: CoreMemoryCitation) -> Self { + Self { + entries: value.entries.into_iter().map(Into::into).collect(), + thread_ids: value.rollout_ids, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitationEntry { + pub path: String, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} + +impl From for MemoryCitationEntry { + fn from(value: CoreMemoryCitationEntry) -> Self { + Self { + path: value.path, + line_start: value.line_start, + line_end: value.line_end, + note: value.note, + } + } +} + +impl CommandAction { + pub fn into_core(self) -> CoreParsedCommand { + match self { + CommandAction::Read { + command: cmd, + name, + path, + } => CoreParsedCommand::Read { + cmd, + name, + path: path.into_path_buf(), + }, + CommandAction::ListFiles { command: cmd, path } => { + CoreParsedCommand::ListFiles { cmd, path } + } + CommandAction::Search { + command: cmd, + query, + path, + } => CoreParsedCommand::Search { cmd, query, path }, + CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, + } + } + + pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { + match value { + CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { + command: cmd, + name, + path: cwd.join(path), + }, + CoreParsedCommand::ListFiles { cmd, path } => { + CommandAction::ListFiles { command: cmd, path } + } + CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { + command: cmd, + query, + path, + }, + CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + UserMessage { id: String, content: Vec }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AgentMessage { + id: String, + text: String, + #[serde(default)] + phase: Option, + #[serde(default)] + memory_citation: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + /// EXPERIMENTAL - proposed plan item content. The completed plan item is + /// authoritative and may not match the concatenation of `PlanDelta` text. + Plan { id: String, text: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + #[serde(default)] + content: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CommandExecution { + id: String, + /// The command to be executed. + command: String, + /// The command's working directory. + cwd: AbsolutePathBuf, + /// Identifier for the underlying PTY process (when available). + process_id: Option, + #[serde(default)] + source: CommandExecutionSource, + status: CommandExecutionStatus, + /// A best-effort parsing of the command to understand the action(s) it will perform. + /// This returns a list of CommandAction objects because a single shell command may + /// be composed of many commands piped together. + command_actions: Vec, + /// The command's output, aggregated from stdout and stderr. + aggregated_output: Option, + /// The command's exit code. + exit_code: Option, + /// The duration of the command execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mcp_app_resource_uri: Option, + result: Option>, + error: Option, + /// The duration of the MCP tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + DynamicToolCall { + id: String, + namespace: Option, + tool: String, + arguments: JsonValue, + status: DynamicToolCallStatus, + content_items: Option>, + success: Option, + /// The duration of the dynamic tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CollabAgentToolCall { + /// Unique identifier for this collab tool call. + id: String, + /// Name of the collab tool that was invoked. + tool: CollabAgentTool, + /// Current status of the collab tool call. + status: CollabAgentToolCallStatus, + /// Thread ID of the agent issuing the collab request. + sender_thread_id: String, + /// Thread ID of the receiving agent, when applicable. In case of spawn operation, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, + /// Prompt text sent as part of the collab tool call, when available. + prompt: Option, + /// Model requested for the spawned agent, when applicable. + model: Option, + /// Reasoning effort requested for the spawned agent, when applicable. + reasoning_effort: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WebSearch { + id: String, + query: String, + action: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageView { id: String, path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageGeneration { + id: String, + status: String, + revised_prompt: Option, + result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + saved_path: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnteredReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExitedReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ContextCompaction { id: String }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::ImageGeneration { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Lifecycle state for an approval auto-review. +pub enum GuardianApprovalReviewStatus { + InProgress, + Approved, + Denied, + TimedOut, + Aborted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Source that produced a terminal approval auto-review decision. +pub enum AutoReviewDecisionSource { + Agent, +} + +impl From for AutoReviewDecisionSource { + fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { + match value { + CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Risk level assigned by approval auto-review. +pub enum GuardianRiskLevel { + Low, + Medium, + High, + Critical, +} + +impl From for GuardianRiskLevel { + fn from(value: CoreGuardianRiskLevel) -> Self { + match value { + CoreGuardianRiskLevel::Low => Self::Low, + CoreGuardianRiskLevel::Medium => Self::Medium, + CoreGuardianRiskLevel::High => Self::High, + CoreGuardianRiskLevel::Critical => Self::Critical, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Authorization level assigned by approval auto-review. +pub enum GuardianUserAuthorization { + Unknown, + Low, + Medium, + High, +} + +impl From for GuardianUserAuthorization { + fn from(value: CoreGuardianUserAuthorization) -> Self { + match value { + CoreGuardianUserAuthorization::Unknown => Self::Unknown, + CoreGuardianUserAuthorization::Low => Self::Low, + CoreGuardianUserAuthorization::Medium => Self::Medium, + CoreGuardianUserAuthorization::High => Self::High, + } + } +} + +/// [UNSTABLE] Temporary approval auto-review payload used by +/// `item/autoApprovalReview/*` notifications. This shape is expected to change +/// soon. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApprovalReview { + pub status: GuardianApprovalReviewStatus, + pub risk_level: Option, + pub user_authorization: Option, + pub rationale: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianCommandSource { + Shell, + UnifiedExec, +} + +impl From for GuardianCommandSource { + fn from(value: CoreGuardianCommandSource) -> Self { + match value { + CoreGuardianCommandSource::Shell => Self::Shell, + CoreGuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +impl From for CoreGuardianCommandSource { + fn from(value: GuardianCommandSource) -> Self { + match value { + GuardianCommandSource::Shell => Self::Shell, + GuardianCommandSource::UnifiedExec => Self::UnifiedExec, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianCommandReviewAction { + pub source: GuardianCommandSource, + pub command: String, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianExecveReviewAction { + pub source: GuardianCommandSource, + pub program: String, + pub argv: Vec, + pub cwd: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApplyPatchReviewAction { + pub cwd: AbsolutePathBuf, + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianNetworkAccessReviewAction { + pub target: String, + pub host: String, + pub protocol: NetworkApprovalProtocol, + pub port: u16, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianMcpToolCallReviewAction { + pub server: String, + pub tool_name: String, + pub connector_id: Option, + pub connector_name: Option, + pub tool_title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianRequestPermissionsReviewAction { + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum GuardianApprovalReviewAction { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Command { + source: GuardianCommandSource, + command: String, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ApplyPatch { + cwd: AbsolutePathBuf, + files: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, +} + +impl From for GuardianApprovalReviewAction { + fn from(value: CoreGuardianAssessmentAction) -> Self { + match value { + CoreGuardianAssessmentAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + CoreGuardianAssessmentAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + CoreGuardianAssessmentAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + CoreGuardianAssessmentAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.into(), + port, + }, + CoreGuardianAssessmentAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + CoreGuardianAssessmentAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +impl From for CoreGuardianAssessmentAction { + fn from(value: GuardianApprovalReviewAction) -> Self { + match value { + GuardianApprovalReviewAction::Command { + source, + command, + cwd, + } => Self::Command { + source: source.into(), + command, + cwd, + }, + GuardianApprovalReviewAction::Execve { + source, + program, + argv, + cwd, + } => Self::Execve { + source: source.into(), + program, + argv, + cwd, + }, + GuardianApprovalReviewAction::ApplyPatch { cwd, files } => { + Self::ApplyPatch { cwd, files } + } + GuardianApprovalReviewAction::NetworkAccess { + target, + host, + protocol, + port, + } => Self::NetworkAccess { + target, + host, + protocol: protocol.to_core(), + port, + }, + GuardianApprovalReviewAction::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + } => Self::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + }, + GuardianApprovalReviewAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} + +impl From for WebSearchAction { + fn from(value: codex_protocol::models::WebSearchAction) -> Self { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } + } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, + } + } +} + +impl From for ThreadItem { + fn from(value: CoreTurnItem) -> Self { + match value { + CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + id: user.id, + content: user.content.into_iter().map(UserInput::from).collect(), + }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, + CoreTurnItem::AgentMessage(agent) => { + let text = agent + .content + .into_iter() + .map(|entry| match entry { + CoreAgentMessageContent::Text { text } => text, + }) + .collect::(); + ThreadItem::AgentMessage { + id: agent.id, + text, + phase: agent.phase, + memory_citation: agent.memory_citation.map(Into::into), + } + } + CoreTurnItem::Plan(plan) => ThreadItem::Plan { + id: plan.id, + text: plan.text, + }, + CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { + id: reasoning.id, + summary: reasoning.summary_text, + content: reasoning.raw_content, + }, + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + id: search.id, + query: search.query, + action: Some(WebSearchAction::from(search.action)), + }, + CoreTurnItem::ImageView(image) => ThreadItem::ImageView { + id: image.id, + path: image.path, + }, + CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + result: image.result, + saved_path: image.saved_path, + }, + CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { + id: file_change.id, + changes: convert_patch_changes(&file_change.changes), + status: file_change + .status + .as_ref() + .map(PatchApplyStatus::from) + .unwrap_or(PatchApplyStatus::InProgress), + }, + CoreTurnItem::McpToolCall(mcp) => { + let duration_ms = mcp + .duration + .and_then(|duration| i64::try_from(duration.as_millis()).ok()); + + ThreadItem::McpToolCall { + id: mcp.id, + server: mcp.server, + tool: mcp.tool, + status: McpToolCallStatus::from(mcp.status), + arguments: mcp.arguments, + mcp_app_resource_uri: mcp.mcp_app_resource_uri, + result: mcp.result.map(McpToolCallResult::from).map(Box::new), + error: mcp.error.map(McpToolCallError::from), + duration_ms, + } + } + CoreTurnItem::ContextCompaction(compaction) => { + ThreadItem::ContextCompaction { id: compaction.id } + } + } + } +} + +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for CommandExecutionStatus { + fn from(value: CoreExecCommandStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CoreExecCommandStatus> for CommandExecutionStatus { + fn from(value: &CoreExecCommandStatus) -> Self { + match value { + CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, + CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, + CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, + } + } +} + +v2_enum_from_core! { + #[derive(Default)] + pub enum CommandExecutionSource from CoreExecCommandSource { + #[default] + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentTool { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update { move_path: Option }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for PatchApplyStatus { + fn from(value: CorePatchApplyStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CorePatchApplyStatus> for PatchApplyStatus { + fn from(value: &CorePatchApplyStatus) -> Self { + match value { + CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, + CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, + CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, + } + } +} + +impl From for McpToolCallStatus { + fn from(value: CoreMcpToolCallStatus) -> Self { + match value { + CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, + CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, + CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Interrupted, + Completed, + Errored, + Shutdown, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Interrupted => Self { + status: CollabAgentStatus::Interrupted, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started. + #[ts(type = "number")] + pub started_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewStartedNotification { + pub thread_id: String, + pub turn_id: String, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewCompletedNotification { + pub thread_id: String, + pub turn_id: String, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub decision_source: AutoReviewDecisionSource, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed. + #[ts(type = "number")] + pub completed_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RawResponseItemCompletedNotification { + pub thread_id: String, + pub turn_id: String, + pub item: ResponseItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should +/// not assume concatenated deltas match the completed plan item content. +pub struct PlanDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryPartAddedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub content_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} +/// Deprecated legacy notification for `apply_patch` textual output. +/// +/// The server no longer emits this notification. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangePatchUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub changes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unique identifier for this specific approval callback. + /// + /// For regular shell/unified_exec approvals, this is null. + /// + /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to + /// one parent `itemId`, so `approvalId` is a distinct opaque callback id + /// (a UUID) used to disambiguate routing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub approval_id: Option, + /// Optional explanatory reason (e.g. request for network access). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub reason: Option, + /// Optional context for a managed-network approval prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, + /// The command to be executed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command: Option, + /// The command's working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub cwd: Option, + /// Best-effort parsed command actions for friendly display. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command_actions: Option>, + /// Optional additional permissions requested for this command. + #[experimental("item/commandExecution/requestApproval.additionalPermissions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub additional_permissions: Option, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_execpolicy_amendment: Option, + /// Optional proposed network policy amendments (allow/deny host) for future requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_network_policy_amendments: Option>, + /// Ordered list of decisions the client may present for this prompt. + #[experimental("item/commandExecution/requestApproval.availableDecisions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub available_decisions: Option>, +} + +impl CommandExecutionRequestApprovalParams { + pub fn strip_experimental_fields(&mut self) { + // TODO: Avoid hardcoding individual experimental fields here. + // We need a generic outbound compatibility design for stripping or + // otherwise handling experimental server->client payloads. + self.additional_permissions = None; + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalResponse { + pub decision: CommandExecutionApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Optional explanatory reason (e.g. request for extra write access). + #[ts(optional = nullable)] + pub reason: Option, + /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root + /// for the remainder of the session (unclear if this is honored today). + #[ts(optional = nullable)] + pub grant_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalResponse { + pub decision: FileChangeApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + pub namespace: Option, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallResponse { + pub content_items: Vec, + pub success: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallOutputContentItem { + #[serde(rename_all = "camelCase")] + InputText { text: String }, + #[serde(rename_all = "camelCase")] + InputImage { image_url: String }, +} + +impl From + for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem +{ + fn from(item: DynamicToolCallOutputContentItem) -> Self { + match item { + DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, + DynamicToolCallOutputContentItem::InputImage { image_url } => { + Self::InputImage { image_url } + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its required options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub answers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs new file mode 100644 index 0000000000..9fd9384076 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -0,0 +1,703 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; +use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; +use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; +use codex_protocol::mcp::Tool as McpTool; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + #[ts(optional = nullable)] + pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatus { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusResponse { + pub data: Vec, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + #[ts(optional = nullable)] + pub thread_id: Option, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallParams { + pub thread_id: String, + pub server: String, + pub tool: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub arguments: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallResponse { + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub structured_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_error: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust + // representation of MCP content blocks. We intentionally use `serde_json::Value` here because + // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types + // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust + // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. + pub content: Vec, + pub structured_content: Option, + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +impl From for McpServerToolCallResponse { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta, + } + } +} + +impl From for McpToolCallResult { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + meta: result.meta, + } + } +} + +impl From for McpToolCallError { + fn from(error: CoreMcpToolCallError) -> Self { + Self { + message: error.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerStartupState { + Starting, + Ready, + Failed, + Cancelled, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatusUpdatedNotification { + pub name: String, + pub status: McpServerStartupState, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationAction { + Accept, + Decline, + Cancel, +} + +impl McpServerElicitationAction { + pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { + match self { + Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, + Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, + Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, + } + } +} + +impl From for rmcp::model::ElicitationAction { + fn from(value: McpServerElicitationAction) -> Self { + match value { + McpServerElicitationAction::Accept => Self::Accept, + McpServerElicitationAction::Decline => Self::Decline, + McpServerElicitationAction::Cancel => Self::Cancel, + } + } +} + +impl From for McpServerElicitationAction { + fn from(value: rmcp::model::ElicitationAction) -> Self { + match value { + rmcp::model::ElicitationAction::Accept => Self::Accept, + rmcp::model::ElicitationAction::Decline => Self::Decline, + rmcp::model::ElicitationAction::Cancel => Self::Cancel, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestParams { + pub thread_id: String, + /// Active Codex turn when this elicitation was observed, if app-server could correlate one. + /// + /// This is nullable because MCP models elicitation as a standalone server-to-client request + /// identified by the MCP server request id. It may be triggered during a turn, but turn + /// context is app-server correlation rather than part of the protocol identity of the + /// elicitation itself. + pub turn_id: Option, + pub server_name: String, + #[serde(flatten)] + pub request: McpServerElicitationRequest, + // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated + // McpToolCall item id here as an optional field. The current core event does not carry that + // association. +} + +/// Typed form schema for MCP `elicitation/create` requests. +/// +/// This matches the `requestedSchema` shape from the MCP 2025-11-25 +/// `ElicitRequestFormParams` schema. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationSchema { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "$schema")] + pub schema_uri: Option, + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationObjectType, + pub properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub required: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationObjectType { + Object, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationPrimitiveSchema { + Enum(McpElicitationEnumSchema), + String(McpElicitationStringSchema), + Number(McpElicitationNumberSchema), + Boolean(McpElicitationBooleanSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationStringSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationStringType { + String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum McpElicitationStringFormat { + Email, + Uri, + Date, + DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationNumberSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationNumberType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationNumberType { + Number, + Integer, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationBooleanSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationBooleanType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationBooleanType { + Boolean, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationEnumSchema { + SingleSelect(McpElicitationSingleSelectEnumSchema), + MultiSelect(McpElicitationMultiSelectEnumSchema), + Legacy(McpElicitationLegacyTitledEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationLegacyTitledEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "enumNames")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationSingleSelectEnumSchema { + Untitled(McpElicitationUntitledSingleSelectEnumSchema), + Titled(McpElicitationTitledSingleSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "oneOf")] + #[ts(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationMultiSelectEnumSchema { + Untitled(McpElicitationUntitledMultiSelectEnumSchema), + Titled(McpElicitationTitledMultiSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationUntitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationTitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationArrayType { + Array, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledEnumItems { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledEnumItems { + #[serde(rename = "anyOf", alias = "oneOf")] + #[ts(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationConstOption { + #[serde(rename = "const")] + #[ts(rename = "const")] + pub const_: String, + pub title: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationRequest { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Form { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: McpElicitationSchema, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Url { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, +} + +impl TryFrom for McpServerElicitationRequest { + type Error = serde_json::Error; + + fn try_from(value: CoreElicitationRequest) -> Result { + match value { + CoreElicitationRequest::Form { + meta, + message, + requested_schema, + } => Ok(Self::Form { + meta, + message, + requested_schema: serde_json::from_value(requested_schema)?, + }), + CoreElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => Ok(Self::Url { + meta, + message, + url, + elicitation_id, + }), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestResponse { + pub action: McpServerElicitationAction, + /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + /// + /// This is nullable because decline/cancel responses have no content. + pub content: Option, + /// Optional client metadata for form-mode action handling. + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +impl From for rmcp::model::CreateElicitationResult { + fn from(value: McpServerElicitationRequestResponse) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::CreateElicitationResult) -> Self { + Self { + action: value.action.into(), + content: value.content, + meta: None, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs new file mode 100644 index 0000000000..df8a363f82 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -0,0 +1,53 @@ +mod shared; + +mod account; +mod apps; +mod collaboration_mode; +mod command_exec; +mod config; +mod device_key; +mod experimental_feature; +mod feedback; +mod fs; +mod hook; +mod item; +mod mcp; +mod model; +mod notification; +mod permissions; +mod plugin; +mod process; +mod realtime; +mod review; +mod thread; +mod thread_data; +mod turn; +mod windows_sandbox; + +pub use account::*; +pub use apps::*; +pub use collaboration_mode::*; +pub use command_exec::*; +pub use config::*; +pub use device_key::*; +pub use experimental_feature::*; +pub use feedback::*; +pub use fs::*; +pub use hook::*; +pub use item::*; +pub use mcp::*; +pub use model::*; +pub use notification::*; +pub use permissions::*; +pub use plugin::*; +pub use process::*; +pub use realtime::*; +pub use review::*; +pub use shared::*; +pub use thread::*; +pub use thread_data::*; +pub use turn::*; +pub use windows_sandbox::*; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs new file mode 100644 index 0000000000..cd139e9c4b --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -0,0 +1,151 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadResponse { + pub namespace_tools: bool, + pub image_generation: bool, + pub web_search: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelAvailabilityNux { + pub message: String, +} + +impl From for ModelAvailabilityNux { + fn from(value: CoreModelAvailabilityNux) -> Self { + Self { + message: value.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelServiceTier { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Model { + pub id: String, + pub model: String, + pub upgrade: Option, + pub upgrade_info: Option, + pub availability_nux: Option, + pub display_name: String, + pub description: String, + pub hidden: bool, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: ReasoningEffort, + #[serde(default = "default_input_modalities")] + pub input_modalities: Vec, + #[serde(default)] + pub supports_personality: bool, + /// Deprecated: use `serviceTiers` instead. + #[serde(default)] + pub additional_speed_tiers: Vec, + #[serde(default)] + pub service_tiers: Vec, + // Only one model should be marked as default. + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelUpgradeInfo { + pub model: String, + pub upgrade_copy: Option, + pub model_link: Option, + pub migration_markdown: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningEffortOption { + pub reasoning_effort: ReasoningEffort, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/notification.rs b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs new file mode 100644 index 0000000000..8289cf5683 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs @@ -0,0 +1,56 @@ +use super::TurnError; +use crate::RequestId; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeprecationNoticeNotification { + /// Concise summary of what is deprecated. + pub summary: String, + /// Optional extra guidance, such as migration steps or rationale. + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WarningNotification { + /// Optional thread target when the warning applies to a specific thread. + pub thread_id: Option, + /// Concise warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ErrorNotification { + pub error: TurnError, + // Set to true if the error is transient and the app-server process will automatically retry. + // If true, this will not interrupt a turn. + pub will_retry: bool, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ServerRequestResolvedNotification { + pub thread_id: String, + pub request_id: RequestId, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs new file mode 100644 index 0000000000..8ce47e58cb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -0,0 +1,854 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; +use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use ts_rs::TS; + +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalFileSystemPermissions { + /// This will be removed in favor of `entries`. + pub read: Option>, + /// This will be removed in favor of `entries`. + pub write: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub glob_scan_max_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub entries: Option>, +} + +impl From for AdditionalFileSystemPermissions { + fn from(value: CoreFileSystemPermissions) -> Self { + if let Some((read, write)) = value.legacy_read_write_roots() { + let mut entries = Vec::with_capacity( + read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), + ); + if let Some(paths) = read.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(paths) = write.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + })); + } + Self { + read, + write, + glob_scan_max_depth: None, + entries: Some(entries), + } + } else { + Self { + read: None, + write: None, + glob_scan_max_depth: value.glob_scan_max_depth, + entries: Some( + value + .entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + ), + } + } + } +} + +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + let mut permissions = if let Some(entries) = value.entries { + Self { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth: None, + } + } else { + CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) + }; + permissions.glob_scan_max_depth = value.glob_scan_max_depth; + permissions + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalNetworkPermissions { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileNetworkPermissions { + pub enabled: bool, +} + +impl From for AdditionalNetworkPermissions { + fn from(value: CoreNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + Self { + enabled: value.is_enabled(), + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: PermissionProfileNetworkPermissions) -> Self { + if value.enabled { + Self::Enabled + } else { + Self::Restricted + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct RequestPermissionProfile { + pub network: Option, + pub file_system: Option, +} + +impl From for RequestPermissionProfile { + fn from(value: CoreRequestPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreRequestPermissionProfile { + fn from(value: RequestPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +v2_enum_from_core!( + pub enum FileSystemAccessMode from CoreFileSystemAccessMode { + Read, + Write, + None + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +#[ts(export_to = "v2/")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + #[serde(alias = "current_working_directory")] + ProjectRoots { + subpath: Option, + }, + Tmpdir, + SlashTmp, + Unknown { + path: String, + subpath: Option, + }, +} + +impl From for FileSystemSpecialPath { + fn from(value: CoreFileSystemSpecialPath) -> Self { + match value { + CoreFileSystemSpecialPath::Root => Self::Root, + CoreFileSystemSpecialPath::Minimal => Self::Minimal, + CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +impl From for CoreFileSystemSpecialPath { + fn from(value: FileSystemSpecialPath) -> Self { + match value { + FileSystemSpecialPath::Root => Self::Root, + FileSystemSpecialPath::Minimal => Self::Minimal, + FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + FileSystemSpecialPath::Tmpdir => Self::Tmpdir, + FileSystemSpecialPath::SlashTmp => Self::SlashTmp, + FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + GlobPattern { pattern: String }, + Special { value: FileSystemSpecialPath }, +} + +impl From for FileSystemPath { + fn from(value: CoreFileSystemPath) -> Self { + match value { + CoreFileSystemPath::Path { path } => Self::Path { path }, + CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + CoreFileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +impl From for CoreFileSystemPath { + fn from(value: FileSystemPath) -> Self { + match value { + FileSystemPath::Path { path } => Self::Path { path }, + FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + FileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +impl From for FileSystemSandboxEntry { + fn from(value: CoreFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.into(), + } + } +} + +impl From for CoreFileSystemSandboxEntry { + fn from(value: FileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.to_core(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for PermissionProfileFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: PermissionProfileFileSystemPermissions) -> Self { + match value { + PermissionProfileFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + /// Codex owns sandbox construction for this profile. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + network: PermissionProfileNetworkPermissions, + file_system: PermissionProfileFileSystemPermissions, + }, + /// Do not apply an outer sandbox. + Disabled, + /// Filesystem isolation is enforced by an external caller. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: PermissionProfileNetworkPermissions, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + network: network.into(), + file_system: file_system.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions` or the implicit built-in default, + /// such as `:workspace` or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalPermissionProfile { + /// Partial overlay used for per-command permission requests. + pub network: Option, + pub file_system: Option, +} + +impl From for AdditionalPermissionProfile { + fn from(value: CoreAdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } + SandboxPolicy::ExternalSandbox { network_access } => { + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: match network_access { + NetworkAccess::Restricted => CoreNetworkAccess::Restricted, + NetworkAccess::Enabled => CoreNetworkAccess::Enabled, + }, + } + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { + SandboxPolicy::ExternalSandbox { + network_access: match network_access { + CoreNetworkAccess::Restricted => NetworkAccess::Restricted, + CoreNetworkAccess::Enabled => NetworkAccess::Enabled, + }, + } + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + +v2_enum_from_core!( + pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { + Allow, Deny + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkPolicyAmendment { + pub host: String, + pub action: NetworkPolicyRuleAction, +} + +impl NetworkPolicyAmendment { + pub fn into_core(self) -> CoreNetworkPolicyAmendment { + CoreNetworkPolicyAmendment { + host: self.host, + action: self.action.to_core(), + } + } +} + +impl From for NetworkPolicyAmendment { + fn from(value: CoreNetworkPolicyAmendment) -> Self { + Self { + host: value.host, + action: NetworkPolicyRuleAction::from(value.action), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub cwd: AbsolutePathBuf, + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +v2_enum_from_core!( + #[derive(Default)] + pub enum PermissionGrantScope from CorePermissionGrantScope { + #[default] + Turn, + Session + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs new file mode 100644 index 0000000000..09f9325317 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -0,0 +1,649 @@ +use super::AppSummary; +use super::HookEventName; +use super::HookHandlerType; +use super::HookSource; +use super::HookTrustStatus; +use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; +use codex_protocol::protocol::SkillInterface as CoreSkillInterface; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; +use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, + + /// When true, bypass the skills cache and re-scan skills from disk. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_reload: bool, + + /// Optional per-cwd extra roots to scan as user-scoped skills. + #[serde(default)] + #[ts(optional = nullable)] + pub per_cwd_extra_user_roots: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListExtraRootsForCwd { + pub cwd: PathBuf, + pub extra_user_roots: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddParams { + pub source: String, + #[ts(optional = nullable)] + pub ref_name: Option, + #[ts(optional = nullable)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddResponse { + pub marketplace_name: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveParams { + pub marketplace_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveResponse { + pub marketplace_name: String, + pub installed_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeParams { + #[ts(optional = nullable)] + pub marketplace_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeResponse { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeErrorInfo { + pub marketplace_name: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListParams { + /// Optional working directories used to discover repo marketplaces. When omitted, + /// only home-scoped marketplaces and the official curated marketplace are considered. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, + #[serde(default)] + pub featured_plugin_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadResponse { + pub plugin: PluginDetail, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadParams { + pub remote_marketplace_name: String, + pub remote_plugin_id: String, + pub skill_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadResponse { + pub contents: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveParams { + pub plugin_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveResponse { + pub remote_plugin_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListItem { + pub plugin: PluginSummary, + pub share_url: String, + pub local_plugin_path: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, + System, + Admin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. + pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub dependencies: Option, + pub path: AbsolutePathBuf, + pub scope: SkillScope, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillDependencies { + pub tools: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillToolDependency { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub r#type: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, + pub enabled: bool, + pub is_managed: bool, + pub current_hash: String, + pub trust_status: HookTrustStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginMarketplaceEntry { + pub name: String, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceInterface { + pub display_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginInstallPolicy { + #[serde(rename = "NOT_AVAILABLE")] + #[ts(rename = "NOT_AVAILABLE")] + NotAvailable, + #[serde(rename = "AVAILABLE")] + #[ts(rename = "AVAILABLE")] + Available, + #[serde(rename = "INSTALLED_BY_DEFAULT")] + #[ts(rename = "INSTALLED_BY_DEFAULT")] + InstalledByDefault, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAuthPolicy { + #[serde(rename = "ON_INSTALL")] + #[ts(rename = "ON_INSTALL")] + OnInstall, + #[serde(rename = "ON_USE")] + #[ts(rename = "ON_USE")] + OnUse, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAvailability { + /// Plugin-service currently sends `"ENABLED"` for available remote plugins. + /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps + /// decoding compatible with that upstream response. + #[serde(rename = "AVAILABLE", alias = "ENABLED")] + #[ts(rename = "AVAILABLE")] + #[default] + Available, + #[serde(rename = "DISABLED_BY_ADMIN")] + #[ts(rename = "DISABLED_BY_ADMIN")] + DisabledByAdmin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSummary { + pub id: String, + pub name: String, + pub source: PluginSource, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + /// Availability state for installing and using the plugin. + #[serde(default)] + pub availability: PluginAvailability, + pub interface: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginDetail { + pub marketplace_name: String, + pub marketplace_path: Option, + pub summary: PluginSummary, + pub description: Option, + pub skills: Vec, + pub apps: Vec, + pub mcp_servers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub path: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + /// Starter prompts for the plugin. Capped at 3 entries with a maximum of + /// 128 characters per entry. + pub default_prompt: Option>, + pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. + pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. + pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. + pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PluginSource { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Local { path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Git { + url: String, + path: Option, + ref_name: Option, + sha: Option, + }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallResponse { + pub auth_policy: PluginAuthPolicy, + pub apps_needing_auth: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + short_description: value.short_description, + interface: value.interface.map(SkillInterface::from), + dependencies: value.dependencies.map(SkillDependencies::from), + path: value.path, + scope: value.scope.into(), + enabled: true, + } + } +} + +impl From for SkillInterface { + fn from(value: CoreSkillInterface) -> Self { + Self { + display_name: value.display_name, + short_description: value.short_description, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + icon_small: value.icon_small, + icon_large: value.icon_large, + } + } +} + +impl From for SkillDependencies { + fn from(value: CoreSkillDependencies) -> Self { + Self { + tools: value + .tools + .into_iter() + .map(SkillToolDependency::from) + .collect(), + } + } +} + +impl From for SkillToolDependency { + fn from(value: CoreSkillToolDependency) -> Self { + Self { + r#type: value.r#type, + value: value.value, + description: value.description, + transport: value.transport, + command: value.command, + url: value.url, + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + CoreSkillScope::System => Self::System, + CoreSkillScope::Admin => Self::Admin, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification emitted when watched local skill files change. +/// +/// Treat this as an invalidation signal and re-run `skills/list` with the +/// client's current parameters when refreshed skill metadata is needed. +pub struct SkillsChangedNotification {} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/process.rs b/codex-rs/app-server-protocol/src/protocol/v2/process.rs new file mode 100644 index 0000000000..b70847165e --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/process.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +/// PTY size in character cells for `process/spawn` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Spawn a standalone process (argv vector) without a Codex sandbox on the host +/// where the app server is running. +/// +/// `process/spawn` returns after the process has started and the connection-scoped +/// `processHandle` has been registered. Process output and exit are reported via +/// `process/outputDelta` and `process/exited` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Client-supplied, connection-scoped process handle. + /// + /// Duplicate active handles are rejected on the same connection. The same + /// handle can be reused after the prior process exits. + pub process_handle: String, + /// Absolute working directory for the process. + pub cwd: AbsolutePathBuf, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `process/writeStdin` requests to write stdin bytes. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `process/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the `process/exited` notification. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// cap. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option>, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// timeout. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option>, + /// Optional environment overrides merged into the app-server process + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, +} + +/// Successful response for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnResponse {} + +/// Write stdin bytes to a running `process/spawn` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `process/writeStdin`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinResponse {} + +/// Terminate a running `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, +} + +/// Empty success response for `process/kill`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillResponse {} + +/// Resize a running PTY-backed `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// New PTY size in character cells. + pub size: ProcessTerminalSize, +} + +/// Empty success response for `process/resizePty`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyResponse {} + +/// Stream label for `process/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ProcessOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} + +/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessOutputDeltaNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Output stream this chunk belongs to. + pub stream: ProcessOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// True on the final streamed chunk for this stream when output was + /// truncated by `outputBytesCap`. + pub cap_reached: bool, +} + +/// Final process exit notification for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessExitedNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `process/outputDelta`. + pub stdout: String, + /// Whether stdout reached `outputBytesCap`. + /// + /// In streaming mode, stdout is empty and cap state is also reported on the + /// final stdout `process/outputDelta` notification. + pub stdout_cap_reached: bool, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `process/outputDelta`. + pub stderr: String, + /// Whether stderr reached `outputBytesCap`. + /// + /// In streaming mode, stderr is empty and cap state is also reported on the + /// final stderr `process/outputDelta` notification. + pub stderr_cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs new file mode 100644 index 0000000000..c6ea0744de --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -0,0 +1,241 @@ +use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::RealtimeVoicesList; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +/// EXPERIMENTAL - thread realtime audio chunk. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAudioChunk { + pub data: String, + pub sample_rate: u32, + pub num_channels: u16, + pub samples_per_channel: Option, + pub item_id: Option, +} + +impl From for ThreadRealtimeAudioChunk { + fn from(value: CoreRealtimeAudioFrame) -> Self { + let CoreRealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +impl From for CoreRealtimeAudioFrame { + fn from(value: ThreadRealtimeAudioChunk) -> Self { + let ThreadRealtimeAudioChunk { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +/// EXPERIMENTAL - start a thread-scoped realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartParams { + pub thread_id: String, + /// Selects text or audio output for the realtime session. Transport and voice stay + /// independent so clients can choose how they connect separately from what the model emits. + pub output_modality: RealtimeOutputModality, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub prompt: Option>, + #[ts(optional = nullable)] + pub realtime_session_id: Option, + #[ts(optional = nullable)] + pub transport: Option, + #[ts(optional = nullable)] + pub voice: Option, +} + +/// EXPERIMENTAL - transport used by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/", tag = "type")] +pub enum ThreadRealtimeStartTransport { + Websocket, + Webrtc { + /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the + /// realtime events data channel. + sdp: String, + }, +} + +/// EXPERIMENTAL - response for starting thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartResponse {} + +/// EXPERIMENTAL - append audio input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioParams { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - response for appending realtime audio input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioResponse {} + +/// EXPERIMENTAL - append text input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextParams { + pub thread_id: String, + pub text: String, +} + +/// EXPERIMENTAL - response for appending realtime text input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextResponse {} + +/// EXPERIMENTAL - stop thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopParams { + pub thread_id: String, +} + +/// EXPERIMENTAL - response for stopping thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopResponse {} + +/// EXPERIMENTAL - list voices supported by thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesParams {} + +/// EXPERIMENTAL - response for listing supported realtime voices. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesResponse { + pub voices: RealtimeVoicesList, +} + +/// EXPERIMENTAL - emitted when thread realtime startup is accepted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartedNotification { + pub thread_id: String, + pub realtime_session_id: Option, + pub version: RealtimeConversationVersion, +} + +/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeItemAddedNotification { + pub thread_id: String, + pub item: JsonValue, +} + +/// EXPERIMENTAL - flat transcript delta emitted whenever realtime +/// transcript text changes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDeltaNotification { + pub thread_id: String, + pub role: String, + /// Live transcript delta from the realtime event. + pub delta: String, +} + +/// EXPERIMENTAL - final transcript text emitted when realtime completes +/// a transcript part. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDoneNotification { + pub thread_id: String, + pub role: String, + /// Final complete text for the transcript part. + pub text: String, +} + +/// EXPERIMENTAL - streamed output audio emitted by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeOutputAudioDeltaNotification { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeSdpNotification { + pub thread_id: String, + pub sdp: String, +} + +/// EXPERIMENTAL - emitted when thread realtime encounters an error. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeErrorNotification { + pub thread_id: String, + pub message: String, +} + +/// EXPERIMENTAL - emitted when thread realtime transport closes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeClosedNotification { + pub thread_id: String, + pub reason: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/review.rs b/codex-rs/app-server-protocol/src/protocol/v2/review.rs new file mode 100644 index 0000000000..82ec5b6f59 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/review.rs @@ -0,0 +1,65 @@ +use super::Turn; +use super::shared::v2_enum_from_core; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { + Inline, Detached + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartParams { + pub thread_id: String, + pub target: ReviewTarget, + + /// Where to run the review: inline (default) on the current thread or + /// detached on a new thread (returned in `reviewThreadId`). + #[serde(default)] + #[ts(optional = nullable)] + pub delivery: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartResponse { + pub turn: Turn, + /// Identifies the thread where the review runs. + /// + /// For inline reviews, this is the original thread id. + /// For detached reviews, this is the id of the new review thread. + pub review_thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ReviewTarget { + /// Review the working tree: staged, unstaged, and untracked files. + UncommittedChanges, + + /// Review changes between the current branch and the given base branch. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + BaseBranch { branch: String }, + + /// Review the changes introduced by a specific commit. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Commit { + sha: String, + /// Optional human-readable label (e.g., commit subject) for UIs. + title: Option, + }, + + /// Arbitrary instructions, equivalent to the old free-form prompt. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Custom { instructions: String }, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs new file mode 100644 index 0000000000..9ec1fb80cb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -0,0 +1,316 @@ +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use either snake_case or kebab-case. +macro_rules! v2_enum_from_core { + ( + $(#[$enum_meta:meta])* + pub enum $Name:ident from $Src:path { + $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? + } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + $(#[$enum_meta])* + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { + $( $(#[$variant_meta])* $Variant ),+ + } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +pub(super) use v2_enum_from_core; + +pub(super) const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + +/// This translation layer make sure that we expose codex error code in camel case. +/// +/// When an upstream HTTP status is available (for example, from the Responses API or a provider), +/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CodexErrorInfo { + ContextWindowExceeded, + UsageLimitExceeded, + ServerOverloaded, + CyberPolicy, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Failed to connect to the response SSE stream. + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + /// The response SSE stream disconnected in the middle of a turn before completion. + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Reached the retry limit for responses. + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, + Other, +} + +impl From for CodexErrorInfo { + fn from(value: CoreCodexErrorInfo) -> Self { + match value { + CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, + CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { + CodexErrorInfo::HttpConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { + CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, + CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, + CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, + CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, + CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, + CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { + CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + } + CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } + } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } + CoreCodexErrorInfo::Other => CodexErrorInfo::Other, + } + } +} + +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + #[experimental("askForApproval.granular")] + Granular { + sandbox_approval: bool, + rules: bool, + #[serde(default)] + skill_approval: bool, + #[serde(default)] + request_permissions: bool, + mcp_elicitations: bool, + }, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Granular { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + }), + AskForApproval::Never => CoreAskForApproval::Never, + } + } +} + +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { + sandbox_approval: granular_config.sandbox_approval, + rules: granular_config.rules, + skill_approval: granular_config.skill_approval, + request_permissions: granular_config.request_permissions, + mcp_elicitations: granular_config.mcp_elicitations, + }, + CoreAskForApproval::Never => AskForApproval::Never, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] +/// Configures who approval requests are routed to for review. Examples +/// include sandbox escapes, blocked network access, MCP approval prompts, and +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully +/// prompted subagent to gather relevant context and apply a risk-based +/// decision framework before approving or denying the request. +pub enum ApprovalsReviewer { + #[serde(rename = "user")] + User, + #[serde(rename = "guardian_subagent", alias = "auto_review")] + AutoReview, +} + +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + +impl ApprovalsReviewer { + pub fn to_core(self) -> CoreApprovalsReviewer { + match self { + ApprovalsReviewer::User => CoreApprovalsReviewer::User, + ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, + } + } +} + +impl From for ApprovalsReviewer { + fn from(value: CoreApprovalsReviewer) -> Self { + match value { + CoreApprovalsReviewer::User => ApprovalsReviewer::User, + CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs new file mode 100644 index 0000000000..5314c73b00 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -0,0 +1,3566 @@ +use super::*; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::FileChangeItem; +use codex_protocol::items::ImageViewItem; +use codex_protocol::items::McpToolCallItem; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::ReasoningItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::time::Duration; + +fn absolute_path_string(path: &str) -> String { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).display().to_string() +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).abs() +} + +fn test_absolute_path() -> AbsolutePathBuf { + absolute_path("readable") +} + +#[test] +fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), + "\"guardian_subagent\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::AutoReview + }; + assert_eq!(expected, reviewer); + } +} + +#[test] +fn turn_defaults_legacy_missing_items_view_to_full() { + let turn: Turn = serde_json::from_value(json!({ + "id": "turn_123", + "items": [], + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null, + })) + .expect("legacy turn should deserialize"); + + assert_eq!(turn.items_view, TurnItemsView::Full); +} + +#[test] +fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); +} + +#[test] +fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); +} + +#[test] +fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); +} + +#[test] +fn collab_agent_state_maps_interrupted_status() { + assert_eq!( + CollabAgentState::from(CoreAgentStatus::Interrupted), + CollabAgentState { + status: CollabAgentStatus::Interrupted, + message: None, + } + ); +} + +#[test] +fn external_agent_config_plugins_details_round_trip() { + let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + })) + .expect("plugins migration item should deserialize"); + + assert_eq!( + item, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + } + ); +} + +#[test] +fn external_agent_config_import_params_accept_legacy_plugin_details() { + let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + }] + })) + .expect("legacy plugin import params should deserialize"); + + assert_eq!( + params, + ExternalAgentConfigImportParams { + migration_items: vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + }], + } + ); +} + +#[test] +fn command_execution_request_approval_rejects_relative_additional_permission_paths() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "command": "cat file", + "cwd": absolute_path_string("tmp"), + "commandActions": null, + "reason": null, + "networkApprovalContext": null, + "additionalPermissions": { + "network": null, + "fileSystem": { + "read": ["relative/path"], + "write": null + } + }, + "proposedExecpolicyAmendment": null, + "proposedNetworkPolicyAmendments": null, + "availableDecisions": null + })) + .expect_err("relative additional permission paths should fail"); + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} + +#[test] +fn permissions_request_approval_uses_request_permission_profile() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions request should deserialize"); + + assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!( + params.permissions, + RequestPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreRequestPermissionProfile::from(params.permissions), + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_rejects_macos_permissions() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": null, + "fileSystem": null, + "macos": { + "preferences": "read_only", + "automations": "none", + "launchServices": false, + "accessibility": false, + "calendar": false, + "reminders": false, + "contacts": "none", + }, + }, + })) + .expect_err("permissions request should reject macos permissions"); + + assert!( + err.to_string().contains("unknown field `macos`"), + "unexpected error: {err}" + ); +} + +#[test] +fn additional_file_system_permissions_preserves_canonical_entries() { + let core_permissions = CoreFileSystemPermissions { + entries: vec![ + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Special { + value: CoreFileSystemSpecialPath::Root, + }, + access: CoreFileSystemAccessMode::Write, + }, + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: None, + write: None, + glob_scan_max_depth: NonZeroUsize::new(2), + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_populates_entries_for_legacy_roots() { + let read_only_path = absolute_path("read-only"); + let read_write_path = absolute_path("read-write"); + let core_permissions = CoreFileSystemPermissions::from_read_write_roots( + Some(vec![read_only_path.clone()]), + Some(vec![read_write_path.clone()]), + ); + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: Some(vec![read_only_path.clone()]), + write: Some(vec![read_write_path.clone()]), + glob_scan_max_depth: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_path, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_write_path, + }, + access: FileSystemAccessMode::Write, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "read": null, + "write": null, + "globScanMaxDepth": 0, + "entries": [], + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { + entries: vec![CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + } + ); + assert_eq!( + CoreManagedFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "type": "restricted", + "entries": [], + "globScanMaxDepth": 0, + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { + let special_path = serde_json::from_value::(json!({ + "kind": "current_working_directory", + })) + .expect("legacy cwd special path should deserialize"); + + assert_eq!( + special_path, + FileSystemSpecialPath::ProjectRoots { subpath: None } + ); + assert_eq!( + serde_json::to_value(&special_path).expect("serialize special path"), + json!({ + "kind": "project_roots", + "subpath": null, + }) + ); +} + +#[test] +fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let response = serde_json::from_value::(json!({ + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_response_defaults_scope_to_turn() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + })) + .expect("response should deserialize"); + + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); +} + +#[test] +fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); +} + +#[test] +fn fs_get_metadata_response_round_trips_minimal_fields() { + let response = FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + created_at_ms: 123, + modified_at_ms: 456, + }; + + let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); + assert_eq!( + value, + json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 123, + "modifiedAtMs": 456, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/getMetadata response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_response_round_trips_base64_data() { + let response = FsReadFileResponse { + data_base64: "aGVsbG8=".to_string(), + }; + + let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); + assert_eq!( + value, + json!({ + "dataBase64": "aGVsbG8=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/readFile response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_params_round_trip() { + let params = FsReadFileParams { + path: absolute_path("tmp/example.txt"), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.txt"), + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/readFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_create_params_round_trip_uses_protection_policy() { + let params = DeviceKeyCreateParams { + protection_policy: None, + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize device/key/create params"); + assert_eq!( + value, + json!({ + "accountUserId": "account-user-1", + "clientId": "cli_123", + "protectionPolicy": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/create params"); + assert_eq!(decoded, params); + + let params = DeviceKeyCreateParams { + protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + }; + let value = serde_json::to_value(¶ms) + .expect("serialize device/key/create params with protection policy"); + assert_eq!( + value, + json!({ + "accountUserId": "account-user-1", + "clientId": "cli_123", + "protectionPolicy": "allow_os_protected_nonextractable", + }) + ); +} + +#[test] +fn device_key_create_response_round_trips_protection_class() { + let response = DeviceKeyCreateResponse { + key_id: "dk_123".to_string(), + public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(), + algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, + protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable, + }; + + let value = serde_json::to_value(&response).expect("serialize device/key/create response"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE", + "algorithm": "ecdsa_p256_sha256", + "protectionClass": "os_protected_nonextractable", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/create response"); + assert_eq!(decoded, response); +} + +#[test] +fn device_key_sign_params_round_trip_uses_accepted_payload_enum() { + let params = DeviceKeySignParams { + key_id: "dk_123".to_string(), + payload: DeviceKeySignPayload::RemoteControlClientConnection { + nonce: "nonce-1".to_string(), + audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, + session_id: "wssess_123".to_string(), + target_origin: "https://chatgpt.com".to_string(), + target_path: "/api/codex/remote/control/client".to_string(), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), + token_expires_at: 1_700_000_000, + scopes: vec!["remote_control_controller_websocket".to_string()], + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "payload": { + "type": "remoteControlClientConnection", + "nonce": "nonce-1", + "audience": "remote_control_client_websocket", + "sessionId": "wssess_123", + "targetOrigin": "https://chatgpt.com", + "targetPath": "/api/codex/remote/control/client", + "accountUserId": "account-user-1", + "clientId": "cli_123", + "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + "tokenExpiresAt": 1_700_000_000, + "scopes": ["remote_control_controller_websocket"], + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign params"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_sign_params_round_trip_uses_enrollment_payload() { + let params = DeviceKeySignParams { + key_id: "dk_123".to_string(), + payload: DeviceKeySignPayload::RemoteControlClientEnrollment { + nonce: "nonce-1".to_string(), + audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, + challenge_id: "rch_123".to_string(), + target_origin: "https://chatgpt.com".to_string(), + target_path: "/wham/remote/control/client/enroll".to_string(), + account_user_id: "account-user-1".to_string(), + client_id: "cli_123".to_string(), + device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" + .to_string(), + challenge_expires_at: 1_700_000_000, + }, + }; + + let value = serde_json::to_value(¶ms) + .expect("serialize device/key/sign params with enrollment payload"); + assert_eq!( + value, + json!({ + "keyId": "dk_123", + "payload": { + "type": "remoteControlClientEnrollment", + "nonce": "nonce-1", + "audience": "remote_control_client_enrollment", + "challengeId": "rch_123", + "targetOrigin": "https://chatgpt.com", + "targetPath": "/wham/remote/control/client/enroll", + "accountUserId": "account-user-1", + "clientId": "cli_123", + "deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", + "challengeExpiresAt": 1_700_000_000, + }, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign params with enrollment payload"); + assert_eq!(decoded, params); +} + +#[test] +fn device_key_sign_response_returns_signed_payload_bytes() { + let response = DeviceKeySignResponse { + signature_der_base64: "MEUCIQD".to_string(), + signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(), + algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, + }; + + let value = serde_json::to_value(&response).expect("serialize device/key/sign response"); + assert_eq!( + value, + json!({ + "signatureDerBase64": "MEUCIQD", + "signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA", + "algorithm": "ecdsa_p256_sha256", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize device/key/sign response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_create_directory_params_round_trip_with_default_recursive() { + let params = FsCreateDirectoryParams { + path: absolute_path("tmp/example"), + recursive: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example"), + "recursive": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/createDirectory params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_write_file_params_round_trip_with_base64_data() { + let params = FsWriteFileParams { + path: absolute_path("tmp/example.bin"), + data_base64: "AAE=".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.bin"), + "dataBase64": "AAE=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/writeFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_copy_params_round_trip_with_recursive_directory_copy() { + let params = FsCopyParams { + source_path: absolute_path("tmp/source"), + destination_path: absolute_path("tmp/destination"), + recursive: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); + assert_eq!( + value, + json!({ + "sourcePath": absolute_path_string("tmp/source"), + "destinationPath": absolute_path_string("tmp/destination"), + "recursive": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/copy params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_params_round_trip() { + let params = ThreadShellCommandParams { + thread_id: "thr_123".to_string(), + command: "printf 'hello world\\n'".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + "command": "printf 'hello world\\n'", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_response_round_trip() { + let response = ThreadShellCommandResponse {}; + + let value = serde_json::to_value(&response).expect("serialize thread/shellCommand response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); +} + +#[test] +fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_round_trips_without_sandbox_policy() { + let params = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "env": null, + "size": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_distinguish_omitted_null_and_value_limits() { + let base = json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + }); + + let expected_omitted = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + let decoded = + serde_json::from_value::(base).expect("deserialize omitted limits"); + assert_eq!(decoded, expected_omitted); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": null, + "timeoutMs": null, + })) + .expect("deserialize disabled limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..expected_omitted.clone() + } + ); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": 123, + "timeoutMs": 456, + })) + .expect("deserialize explicit limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(Some(123)), + timeout_ms: Some(Some(456)), + ..expected_omitted + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn process_control_params_round_trip() { + let write = ProcessWriteStdinParams { + process_handle: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/writeStdin params"); + assert_eq!(decoded, write); + + let resize = ProcessResizePtyParams { + process_handle: "proc-7".to_string(), + size: ProcessTerminalSize { + rows: 50, + cols: 160, + }, + }; + let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/resizePty params"); + assert_eq!(decoded, resize); + + let kill = ProcessKillParams { + process_handle: "proc-7".to_string(), + }; + let value = serde_json::to_value(&kill).expect("serialize process/kill params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + }) + ); + let decoded = + serde_json::from_value::(value).expect("deserialize process/kill"); + assert_eq!(decoded, kill); +} + +#[test] +fn process_notifications_round_trip() { + let delta = ProcessOutputDeltaNotification { + process_handle: "proc-1".to_string(), + stream: ProcessOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/outputDelta"); + assert_eq!(decoded, delta); + + let exited = ProcessExitedNotification { + process_handle: "proc-1".to_string(), + exit_code: 0, + stdout: "out".to_string(), + stdout_cap_reached: false, + stderr: "err".to_string(), + stderr_cap_reached: true, + }; + let value = serde_json::to_value(&exited).expect("serialize process/exited"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "exitCode": 0, + "stdout": "out", + "stdoutCapReached": false, + "stderr": "err", + "stderrCapReached": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/exited"); + assert_eq!(decoded, exited); +} + +#[test] +fn command_execution_output_delta_round_trips() { + let notification = CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "\u{fffd}a\n".to_string(), + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize item/commandExecution/outputDelta notification"); + assert_eq!( + value, + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "delta": "\u{fffd}a\n", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn sandbox_policy_round_trips_external_sandbox_network_access() { + let v2_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: CoreNetworkAccess::Enabled, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_round_trips_read_only_network_access() { + let v2_policy = SandboxPolicy::ReadOnly { + network_access: true, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: true, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_round_trips_request_permissions_flag() { + let v2_policy = AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }) + ); + + let back_to_v2 = AskForApproval::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { + let decoded = serde_json::from_value::(serde_json::json!({ + "granular": { + "sandbox_approval": true, + "rules": false, + "mcp_elicitations": true, + } + })) + .expect("granular approval policy should deserialize"); + + assert_eq!( + decoded, + AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + } + ); +} + +#[test] +fn ask_for_approval_granular_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }); + + assert_eq!(reason, Some("askForApproval.granular")); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::OnRequest,), + None + ); +} + +#[test] +fn profile_v2_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_nested_profile_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_nested_profile_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { + allowed_approval_policies: Some(vec![AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + feature_requirements: None, + hooks: None, + enforce_residency: None, + network: None, + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadStart { + request_id: crate::RequestId::Integer(1), + params: ThreadStartParams { + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadResume { + request_id: crate::RequestId::Integer(2), + params: ThreadResumeParams { + thread_id: "thr_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadFork { + request_id: crate::RequestId::Integer(3), + params: ThreadForkParams { + thread_id: "thr_456".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::TurnStart { + request_id: crate::RequestId::Integer(4), + params: TurnStartParams { + thread_id: "thr_123".to_string(), + input: Vec::new(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn mcp_server_elicitation_response_round_trips_rmcp_result() { + let rmcp_result = rmcp::model::CreateElicitationResult { + action: rmcp::model::ElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + }; + + let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); + assert_eq!( + v2_response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + } + ); + assert_eq!( + rmcp::model::CreateElicitationResult::from(v2_response), + rmcp_result + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_url_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + }) + .expect("URL request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_form_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + }), + }) + .expect("form request should convert"); + + let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + })) + .expect("expected schema should deserialize"); + + assert_eq!( + request, + McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: expected_schema, + } + ); +} + +#[test] +fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { + let schema: McpElicitationSchema = serde_json::from_value(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "title": "Email", + "description": "Work email address", + "format": "email", + "default": "dev@example.com", + }, + "count": { + "type": "integer", + "title": "Count", + "description": "How many items to create", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action", + "default": true, + }, + "legacyChoice": { + "type": "string", + "title": "Action", + "description": "Legacy titled enum form", + "enum": ["allow", "deny"], + "enumNames": ["Allow", "Deny"], + "default": "allow", + }, + }, + "required": ["email", "confirmed"], + })) + .expect("schema should deserialize"); + + assert_eq!( + schema, + McpElicitationSchema { + schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), + type_: McpElicitationObjectType::Object, + properties: BTreeMap::from([ + ( + "confirmed".to_string(), + McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { + type_: McpElicitationBooleanType::Boolean, + title: Some("Confirm".to_string()), + description: Some("Approve the pending action".to_string()), + default: Some(true), + }), + ), + ( + "count".to_string(), + McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: McpElicitationNumberType::Integer, + title: Some("Count".to_string()), + description: Some("How many items to create".to_string()), + minimum: Some(1.0), + maximum: Some(5.0), + default: Some(3.0), + }), + ), + ( + "email".to_string(), + McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { + type_: McpElicitationStringType::String, + title: Some("Email".to_string()), + description: Some("Work email address".to_string()), + min_length: None, + max_length: None, + format: Some(McpElicitationStringFormat::Email), + default: Some("dev@example.com".to_string()), + }), + ), + ( + "legacyChoice".to_string(), + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( + McpElicitationLegacyTitledEnumSchema { + type_: McpElicitationStringType::String, + title: Some("Action".to_string()), + description: Some("Legacy titled enum form".to_string()), + enum_: vec!["allow".to_string(), "deny".to_string()], + enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), + default: Some("allow".to_string()), + }, + )), + ), + ]), + required: Some(vec!["email".to_string(), "confirmed".to_string()]), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_rejects_null_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: Some(json!({ + "persist": "session", + })), + message: "Allow this request?".to_string(), + requested_schema: JsonValue::Null, + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "object", + } + }, + }), + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_response_serializes_nullable_content() { + let response = McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "action": "decline", + "content": null, + "_meta": null, + }) + ); +} + +#[test] +fn sandbox_policy_round_trips_workspace_write_access() { + let v2_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true + })) + .expect("read-only policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + network_access: true + } + ); +} + +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); +} + +#[test] +fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); +} + +#[test] +fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); +} + +#[test] +fn automatic_approval_review_deserializes_aborted_status() { + let review: GuardianApprovalReview = serde_json::from_value(json!({ + "status": "aborted", + "riskLevel": null, + "userAuthorization": null, + "rationale": null + })) + .expect("aborted automatic review should deserialize"); + assert_eq!( + review, + GuardianApprovalReview { + status: GuardianApprovalReviewStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + } + ); +} + +#[test] +fn guardian_approval_review_action_round_trips_command_shape() { + let value = json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/example.sqlite", + "cwd": absolute_path_string("tmp"), + }); + let action: GuardianApprovalReviewAction = + serde_json::from_value(value.clone()).expect("guardian review action"); + + assert_eq!( + action, + GuardianApprovalReviewAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf /tmp/example.sqlite".to_string(), + cwd: absolute_path("tmp"), + } + ); + assert_eq!( + serde_json::to_value(&action).expect("serialize guardian review action"), + value + ); +} + +#[test] +fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); +} + +#[test] +fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); +} + +#[test] +fn core_turn_item_into_thread_item_converts_supported_variants() { + let user_item = TurnItem::UserMessage(UserMessageItem { + id: "user-1".to_string(), + content: vec![ + CoreUserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }, + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + CoreUserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + }); + + assert_eq!( + ThreadItem::from(user_item), + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".to_string(), + }, + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + UserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + } + ); + + let agent_item = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-1".to_string(), + content: vec![ + AgentMessageContent::Text { + text: "Hello ".to_string(), + }, + AgentMessageContent::Text { + text: "world".to_string(), + }, + ], + phase: None, + memory_citation: None, + }); + + assert_eq!( + ThreadItem::from(agent_item), + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "Hello world".to_string(), + phase: None, + memory_citation: None, + } + ); + + let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-2".to_string(), + content: vec![AgentMessageContent::Text { + text: "final".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(CoreMemoryCitation { + entries: vec![CoreMemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + rollout_ids: vec!["rollout-1".to_string()], + }), + }); + + assert_eq!( + ThreadItem::from(agent_item_with_phase), + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(MemoryCitation { + entries: vec![MemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + thread_ids: vec!["rollout-1".to_string()], + }), + } + ); + + let reasoning_item = TurnItem::Reasoning(ReasoningItem { + id: "reasoning-1".to_string(), + summary_text: vec!["line one".to_string(), "line two".to_string()], + raw_content: vec![], + }); + + assert_eq!( + ThreadItem::from(reasoning_item), + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["line one".to_string(), "line two".to_string()], + content: vec![], + } + ); + + let search_item = TurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: CoreWebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }, + }); + + assert_eq!( + ThreadItem::from(search_item), + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + } + ); + + let image_view_item = TurnItem::ImageView(ImageViewItem { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + }); + + assert_eq!( + ThreadItem::from(image_view_item), + ThreadItem::ImageView { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + } + ); + + let file_change_item = TurnItem::FileChange(FileChangeItem { + id: "patch-1".to_string(), + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".to_string(), + }, + )] + .into_iter() + .collect(), + status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), + auto_approved: None, + stdout: Some("Done!".to_string()), + stderr: Some(String::new()), + }); + + assert_eq!( + ThreadItem::from(file_change_item), + ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![FileUpdateChange { + path: "README.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: PatchApplyStatus::Completed, + } + ); + + let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + status: CoreMcpToolCallStatus::InProgress, + result: None, + error: None, + duration: None, + }); + + assert_eq!( + ThreadItem::from(mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + result: None, + error: None, + duration_ms: None, + } + ); + + let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + status: CoreMcpToolCallStatus::Completed, + result: Some(CallToolResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + is_error: Some(false), + meta: Some(json!({"trace": "1"})), + }), + error: None, + duration: Some(Duration::from_millis(42)), + }); + + assert_eq!( + ThreadItem::from(completed_mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::Completed, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: Some(Box::new(McpToolCallResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + meta: Some(json!({"trace": "1"})), + })), + error: None, + duration_ms: Some(42), + } + ); +} + +#[test] +fn skills_list_params_serialization_uses_force_reload() { + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: Vec::new(), + force_reload: false, + per_cwd_extra_user_roots: None, + }) + .unwrap(), + json!({ + "perCwdExtraUserRoots": null, + }), + ); + + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: vec![PathBuf::from("/repo")], + force_reload: true, + per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { + cwd: PathBuf::from("/repo"), + extra_user_roots: vec![PathBuf::from("/shared/skills"), PathBuf::from("/tmp/x")], + }]), + }) + .unwrap(), + json!({ + "cwds": ["/repo"], + "forceReload": true, + "perCwdExtraUserRoots": [ + { + "cwd": "/repo", + "extraUserRoots": ["/shared/skills", "/tmp/x"], + } + ], + }), + ); +} + +#[test] +fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), + json!({ + "type": "local", + "path": local_path_json, + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }) + .unwrap(), + json!({ + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", + }), + ); +} + +#[test] +fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: None, + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": null, + "sparsePaths": null, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: Some("main".to_string()), + sparse_paths: Some(vec!["plugins/foo".to_string()]), + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": "main", + "sparsePaths": ["plugins/foo"], + }), + ); +} + +#[test] +fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: None, + }) + .unwrap(), + json!({ + "marketplaceName": null, + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + MarketplaceUpgradeParams { + marketplace_name: None, + }, + ); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: Some("debug".to_string()), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + }), + ); +} + +#[test] +fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); +} + +#[test] +fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); +} + +#[test] +fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { cwds: None }, + ); +} + +#[test] +fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_install_params_serialization_omits_force_remote_sync() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { + assert_eq!( + serde_json::to_value(PluginSkillReadParams { + remote_marketplace_name: "chatgpt-global".to_string(), + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + skill_name: "plan-work".to_string(), + }) + .unwrap(), + json!({ + "remoteMarketplaceName": "chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "skillName": "plan-work", + }), + ); +} + +#[test] +fn plugin_share_params_and_response_serialization_use_camel_case_fields() { + let plugin_path = if cfg!(windows) { + r"C:\plugins\gmail" + } else { + "/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path: plugin_path.clone(), + remote_plugin_id: None, + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path, + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_url: String::new(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareUrl": "", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + PluginShareListParams {}, + ); + + assert_eq!( + serde_json::to_value(PluginShareDeleteParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); +} + +#[test] +fn plugin_share_list_response_serializes_share_items() { + assert_eq!( + serde_json::to_value(PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + name: "gmail".to_string(), + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: None, + }, + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + local_plugin_path: None, + }], + }) + .unwrap(), + json!({ + "data": [{ + "plugin": { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "availability": "AVAILABLE", + "interface": null, + }, + "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", + "localPluginPath": null, + }], + }), + ); +} + +#[test] +fn plugin_summary_defaults_missing_availability_to_available() { + let summary: PluginSummary = serde_json::from_value(json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + })) + .unwrap(); + + assert_eq!(summary.availability, PluginAvailability::Available); +} + +#[test] +fn plugin_availability_deserializes_enabled_alias() { + let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); + + assert_eq!(availability, PluginAvailability::Available); + assert_eq!( + serde_json::to_value(availability).unwrap(), + json!("AVAILABLE") + ); +} + +#[test] +fn plugin_uninstall_params_serialization_omits_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "gmail@openai-curated", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, + ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "plugins~Plugin_gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "plugins~Plugin_gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }, + ); +} + +#[test] +fn marketplace_remove_response_serializes_nullable_installed_root() { + let installed_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); + let installed_root_json = installed_root.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: Some(installed_root), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": installed_root_json, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: None, + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": null, + }), + ); +} + +#[test] +fn marketplace_upgrade_response_serializes_camel_case_fields() { + let upgraded_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); + let upgraded_root_json = upgraded_root.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: vec![upgraded_root], + errors: vec![MarketplaceUpgradeErrorInfo { + marketplace_name: "broken".to_string(), + message: "failed to clone".to_string(), + }], + }) + .unwrap(), + json!({ + "selectedMarketplaces": ["debug"], + "upgradedRoots": [upgraded_root_json], + "errors": [{ + "marketplaceName": "broken", + "message": "failed to clone", + }], + }), + ); +} + +#[test] +fn codex_error_info_serializes_http_status_code_in_camel_case() { + let value = CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(401), + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "responseTooManyFailedAttempts": { + "httpStatusCode": 401 + } + }) + ); +} + +#[test] +fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); +} + +#[test] +fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_text_and_image_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + }, + { + "type": "inputImage", + "imageUrl": "data:image/png;base64,AAA" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_spec_deserializes_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "deferLoading": true, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert_eq!( + actual, + DynamicToolSpec { + namespace: None, + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }), + defer_loading: true, + } + ); +} + +#[test] +fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": {} + }, + "exposeToContext": false, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert!(actual.defer_loading); +} + +#[test] +fn thread_start_params_preserve_explicit_null_service_tier() { + let params: ThreadStartParams = + serde_json::from_value(json!({ "serviceTier": null })).expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_lifecycle_responses_default_missing_compat_fields() { + let response = json!({ + "thread": { + "id": "thread-id", + "forkedFromId": null, + "preview": "", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 1, + "status": { "type": "idle" }, + "path": null, + "cwd": absolute_path_string("tmp"), + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": absolute_path_string("tmp"), + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { "type": "dangerFullAccess" }, + "reasoningEffort": null + }); + + let start: ThreadStartResponse = + serde_json::from_value(response.clone()).expect("thread/start response"); + let resume: ThreadResumeResponse = + serde_json::from_value(response.clone()).expect("thread/resume response"); + let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + + assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(resume.instruction_sources, Vec::::new()); + assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); +} + +#[test] +fn turn_start_params_preserve_explicit_null_service_tier() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + responsesapi_client_metadata: None, + environments: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permissions: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); +} + +#[test] +fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); +} + +#[test] +fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); +} + +#[test] +fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs new file mode 100644 index 0000000000..ad125da4e6 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -0,0 +1,1146 @@ +use super::ActivePermissionProfile; +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfile; +use super::PermissionProfileSelectionParams; +use super::SandboxMode; +use super::SandboxPolicy; +use super::Thread; +use super::Turn; +use super::TurnEnvironmentParams; +use super::shared::v2_enum_from_core; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; +use codex_protocol::protocol::TokenUsage as CoreTokenUsage; +use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadStartSource { + Startup, + Clear, +} + +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolSpec { + #[ts(optional)] + pub namespace: Option, + pub name: String, + pub description: String, + pub input_schema: JsonValue, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub defer_loading: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DynamicToolSpecDe { + namespace: Option, + name: String, + description: String, + input_schema: JsonValue, + defer_loading: Option, + expose_to_context: Option, +} + +impl<'de> Deserialize<'de> for DynamicToolSpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let DynamicToolSpecDe { + namespace, + name, + description, + input_schema, + defer_loading, + expose_to_context, + } = DynamicToolSpecDe::deserialize(deserializer)?; + + Ok(Self { + namespace, + name, + description, + input_schema, + defer_loading: defer_loading + .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), + }) + } +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive( + Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub service_name: Option, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + #[ts(optional = nullable)] + pub ephemeral: Option, + #[ts(optional = nullable)] + pub session_start_source: Option, + /// Optional sticky environments for this thread. + /// + /// Omitted selects the default environment when environment access is + /// enabled. Empty disables environment access for turns that do not + /// provide a turn override. Non-empty selects the first environment as the + /// current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + #[experimental("thread/start.dynamicTools")] + #[ts(optional = nullable)] + pub dynamic_tools: Option>, + /// Test-only experimental field used to validate experimental gating and + /// schema filtering behavior in a stable way. + #[experimental("thread/start.mockExperimentalField")] + #[ts(optional = nullable)] + pub mock_experimental_field: Option, + /// If true, opt into emitting raw Responses API items on the event stream. + /// This is for internal use only (e.g. Codex Cloud). + #[experimental("thread/start.experimentalRawEvents")] + #[serde(default)] + pub experimental_raw_events: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/start.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodParams { + /// Test-only payload field. + #[ts(optional = nullable)] + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodResponse { + /// Echoes the input `value`. + pub echoed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/start.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are three ways to resume a thread: +/// 1. By thread_id: load the thread from disk by thread_id and resume it. +/// 2. By history: instantiate the thread from memory and resume it. +/// 3. By path: load the thread from disk by path and resume it. +/// +/// The precedence is: history > path > thread_id. +/// If using history or path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadResumeParams { + pub thread_id: String, + + /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + /// If specified, the thread will be resumed with the provided history + /// instead of loaded from disk. + #[experimental("thread/resume.history")] + #[ts(optional = nullable)] + pub history: Option>, + + /// [UNSTABLE] Specify the rollout path to resume from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/resume.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the resumed thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + /// When true, return only thread metadata and live-resume state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after resuming. + #[experimental("thread/resume.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/resume.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/resume.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are two ways to fork a thread: +/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. +/// 2. By path: load the thread from disk by path and fork it into a new thread. +/// +/// If using path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadForkParams { + pub thread_id: String, + + /// [UNSTABLE] Specify the rollout path to fork from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/fork.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the forked thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ephemeral: bool, + /// When true, return only thread metadata and live fork state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after forking. + #[experimental("thread/fork.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/fork.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadForkResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/fork.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeResponse { + pub status: ThreadUnsubscribeStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadUnsubscribeStatus { + NotLoaded, + NotSubscribed, + Unsubscribed, +} + +/// Parameters for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be incremented. + pub thread_id: String, +} + +/// Response for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationResponse { + /// Current out-of-band elicitation count after the increment. + pub count: u64, + /// Whether timeout accounting is paused after applying the increment. + pub paused: bool, +} + +/// Parameters for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be decremented. + pub thread_id: String, +} + +/// Response for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationResponse { + /// Current out-of-band elicitation count after the decrement. + pub count: u64, + /// Whether timeout accounting remains paused after applying the decrement. + pub paused: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateParams { + pub thread_id: String, + /// Patch the stored Git metadata for this thread. + /// Omit a field to leave it unchanged, set it to `null` to clear it, or + /// provide a string to replace the stored value. + #[ts(optional = nullable)] + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataGitInfoUpdateParams { + /// Omit to leave the stored commit unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub sha: Option>, + /// Omit to leave the stored branch unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub branch: Option>, + /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub origin_url: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum ThreadMemoryMode { + Enabled, + Disabled, +} + +impl ThreadMemoryMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } + + pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { + match self { + Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, + Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetParams { + pub thread_id: String, + pub mode: ThreadMemoryMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryResetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandParams { + pub thread_id: String, + /// Shell command string evaluated by the thread's configured shell. + /// Unlike `command/exec`, this intentionally preserves shell syntax + /// such as pipes, redirects, and quoting. This runs unsandboxed with full + /// access rather than inheriting the thread sandbox policy. + pub command: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionParams { + pub thread_id: String, + /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + pub event: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackParams { + pub thread_id: String, + /// The number of turns to drop from the end of the thread. Must be >= 1. + /// + /// This only modifies the thread's history and does not revert local file changes + /// that have been made by the agent. Clients are responsible for reverting these changes. + pub num_turns: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackResponse { + /// The updated thread after applying the rollback, with `turns` populated. + /// + /// The ThreadItems stored in each Turn are lossy since we explicitly do not + /// persist all agent interactions, such as command executions. This is the same + /// behavior as `thread/resume`. + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional sort key; defaults to created_at. + #[ts(optional = nullable)] + pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + #[ts(optional = nullable)] + pub model_providers: Option>, + /// 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>, + /// 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, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, + /// Optional substring filter for the extracted thread title. + #[ts(optional = nullable)] + pub search_term: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadSourceKind { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + VsCode, + Exec, + AppServer, + SubAgent, + SubAgentReview, + SubAgentCompact, + SubAgentThreadSpawn, + SubAgentOther, + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SortDirection { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// 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, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to no limit. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListResponse { + /// Thread ids for sessions currently loaded in memory. + pub data: Vec, + /// 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, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadStatus { + NotLoaded, + Idle, + SystemError, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Active { + active_flags: Vec, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadActiveFlag { + WaitingOnApproval, + WaitingOnUserInput, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadParams { + pub thread_id: String, + /// When true, include turns and their items from rollout history. + #[serde(default)] + pub include_turns: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsParams { + pub thread_id: String, + /// Raw Responses API items to append to the thread's model-visible history. + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListParams { + pub thread_id: String, + /// Opaque cursor to pass to the next call to continue after the last turn. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last turn. + /// if None, there are no more turns to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one turn. + /// Use it with the opposite `sortDirection` to include the anchor turn again + /// and catch updates to that turn. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsageUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub token_usage: ThreadTokenUsage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsage { + pub total: TokenUsageBreakdown, + pub last: TokenUsageBreakdown, + // TODO(aibrahim): make this not optional + #[ts(type = "number | null")] + pub model_context_window: Option, +} + +impl From for ThreadTokenUsage { + fn from(value: CoreTokenUsageInfo) -> Self { + Self { + total: value.total_token_usage.into(), + last: value.last_token_usage.into(), + model_context_window: value.model_context_window, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TokenUsageBreakdown { + #[ts(type = "number")] + pub total_tokens: i64, + #[ts(type = "number")] + pub input_tokens: i64, + #[ts(type = "number")] + pub cached_input_tokens: i64, + #[ts(type = "number")] + pub output_tokens: i64, + #[ts(type = "number")] + pub reasoning_output_tokens: i64, +} + +impl From for TokenUsageBreakdown { + fn from(value: CoreTokenUsage) -> Self { + Self { + total_tokens: value.total_tokens, + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + output_tokens: value.output_tokens, + reasoning_output_tokens: value.reasoning_output_tokens, + } + } +} + +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStatusChangedNotification { + pub thread_id: String, + pub status: ThreadStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadClosedNotification { + pub thread_id: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + +/// Deprecated: Use `ContextCompaction` item type instead. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ContextCompactedNotification { + pub thread_id: String, + pub turn_id: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs new file mode 100644 index 0000000000..07c21e3906 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -0,0 +1,162 @@ +use super::CodexErrorInfo; +use super::ThreadItem; +use super::ThreadStatus; +use super::TurnStatus; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + Custom(String), + SubAgent(CoreSubAgentSource), + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), + // We do not want to render those at the app-server level. + CoreSessionSource::Internal(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, + /// Usually the first user message in the thread, if available. + pub preview: String, + /// Whether the thread is ephemeral and should not be materialized on disk. + pub ephemeral: bool, + /// Model provider used for this thread (for example, 'openai'). + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + #[ts(type = "number")] + pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, + /// Current runtime status for the thread. + pub status: ThreadStatus, + /// [UNSTABLE] Path to the thread on disk. + pub path: Option, + /// Working directory captured for the thread. + pub cwd: AbsolutePathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, + /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + /// (when `includeTurns` is true) responses. + /// For all other responses and notifications returning a Thread, + /// the turns field will be an empty list. + pub turns: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + /// Thread items currently included in this turn payload. + pub items: Vec, + /// Describes how much of `items` has been loaded for this turn. + #[serde(default)] + pub items_view: TurnItemsView, + pub status: TurnStatus, + /// Only populated when the Turn's status is failed. + pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnItemsView { + /// `items` was not loaded for this turn. The field is intentionally empty. + NotLoaded, + /// `items` contains only a display summary for this turn. + Summary, + /// `items` contains every ThreadItem available from persisted app-server history for this turn. + #[default] + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +#[error("{message}")] +pub struct TurnError { + pub message: String, + pub codex_error_info: Option, + #[serde(default)] + pub additional_details: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs new file mode 100644 index 0000000000..38118ab15e --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -0,0 +1,390 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfileSelectionParams; +use super::SandboxPolicy; +use super::Turn; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; +use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::user_input::ByteRange as CoreByteRange; +use codex_protocol::user_input::TextElement as CoreTextElement; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/start.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + /// Override the working directory for this turn and subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this turn and + /// subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for this turn and subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for this turn and subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for this turn and subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for this turn and subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// Override the personality for this turn and subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, + /// Optional JSON Schema used to constrain the final assistant message for + /// this turn. + #[ts(optional = nullable)] + pub output_schema: Option, + + /// EXPERIMENTAL - Set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("turn/start.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/steer.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Required active turn id precondition. The request fails when it does not + /// match the currently active turn. + pub expected_turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerResponse { + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ByteRange { + pub start: usize, + pub end: usize, +} + +impl From for ByteRange { + fn from(value: CoreByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +impl From for CoreByteRange { + fn from(value: ByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextElement { + /// Byte range in the parent `text` buffer that this element occupies. + pub byte_range: ByteRange, + /// Optional human-readable placeholder for the element, displayed in the UI. + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } +} + +impl From for TextElement { + fn from(value: CoreTextElement) -> Self { + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) + } +} + +impl From for CoreTextElement { + fn from(value: TextElement) -> Self { + Self::new(value.byte_range.into(), value.placeholder) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { + text: String, + /// UI-defined spans within `text` used to render or persist special elements. + #[serde(default)] + text_elements: Vec, + }, + Image { + url: String, + }, + LocalImage { + path: PathBuf, + }, + Skill { + name: String, + path: PathBuf, + }, + Mention { + name: String, + path: String, + }, +} + +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { + text, + text_elements, + } => CoreUserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, + } + } +} + +impl From for UserInput { + fn from(value: CoreUserInput) -> Self { + match value { + CoreUserInput::Text { + text, + text_elements, + } => UserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, + CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, + _ => unreachable!("unsupported user input variant"), + } + } +} + +impl UserInput { + pub fn text_char_count(&self) -> usize { + match self { + UserInput::Text { text, .. } => text.chars().count(), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => 0, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification that the turn-level unified diff has changed. +/// Contains the latest aggregated diff across all file changes in the turn. +pub struct TurnDiffUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub explanation: Option, + pub plan: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanStep { + pub step: String, + pub status: TurnPlanStepStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnPlanStepStatus { + Pending, + InProgress, + Completed, +} + +impl From for TurnPlanStep { + fn from(value: CorePlanItemArg) -> Self { + Self { + step: value.step, + status: value.status.into(), + } + } +} + +impl From for TurnPlanStepStatus { + fn from(value: CorePlanStepStatus) -> Self { + match value { + CorePlanStepStatus::Pending => Self::Pending, + CorePlanStepStatus::InProgress => Self::InProgress, + CorePlanStepStatus::Completed => Self::Completed, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs new file mode 100644 index 0000000000..3e090c7bfd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs @@ -0,0 +1,63 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsWorldWritableWarningNotification { + pub sample_paths: Vec, + pub extra_count: usize, + pub failed_scan: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxSetupMode { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxReadiness { + Ready, + NotConfigured, + UpdateRequired, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartParams { + pub mode: WindowsSandboxSetupMode, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartResponse { + pub started: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxReadinessResponse { + pub status: WindowsSandboxReadiness, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupCompletedNotification { + pub mode: WindowsSandboxSetupMode, + pub success: bool, + pub error: Option, +} From 03d3403a4163f9500653c0f8398d87eb02ed46b5 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Tue, 5 May 2026 16:56:43 -0700 Subject: [PATCH 02/12] ci: trigger rusty-v8 releases from tags (#21259) Swap to tag based releasing and allow tags of type `rusty-v8-v*.*.*` --- .github/workflows/rusty-v8-release.yml | 37 +++++++------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 9731948f60..bf9dcbfd6f 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -1,20 +1,12 @@ name: rusty-v8-release on: - workflow_dispatch: - inputs: - release_tag: - description: Optional release tag. Defaults to rusty-v8-v. - required: false - type: string - publish: - description: Publish the staged musl artifacts to a GitHub release. - required: false - default: true - type: boolean + push: + tags: + - "rusty-v8-v*.*.*" concurrency: - group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }} + group: ${{ github.workflow }}::${{ github.ref_name }} cancel-in-progress: false jobs: @@ -43,15 +35,17 @@ jobs: - name: Resolve release tag id: release_tag env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + GITHUB_REF_NAME: ${{ github.ref_name }} V8_VERSION: ${{ steps.v8_version.outputs.version }} shell: bash run: | set -euo pipefail - release_tag="${RELEASE_TAG_INPUT}" - if [[ -z "${release_tag}" ]]; then - release_tag="rusty-v8-v${V8_VERSION}" + expected_release_tag="rusty-v8-v${V8_VERSION}" + release_tag="${GITHUB_REF_NAME}" + if [[ "${release_tag}" != "${expected_release_tag}" ]]; then + echo "Tag ${release_tag} does not match resolved v8 crate version ${V8_VERSION}." >&2 + exit 1 fi echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" @@ -145,7 +139,6 @@ jobs: path: dist/${{ matrix.target }}/* publish-release: - if: ${{ inputs.publish }} needs: - metadata - build @@ -155,16 +148,6 @@ jobs: actions: read steps: - - name: Ensure publishing from default branch - if: ${{ github.ref_name != github.event.repository.default_branch }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2 - exit 1 - - name: Ensure release tag is new env: GH_TOKEN: ${{ github.token }} From 26f355b67b75b040ff16990d1b2e4e8093479213 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 5 May 2026 17:14:29 -0700 Subject: [PATCH 03/12] linux-sandbox: use standalone bundled bwrap (#21255) **Summary** - Add `codex-bwrap`, a standalone `bwrap` binary built from the existing vendored bubblewrap sources. - Remove the linked vendored bwrap path from `codex-linux-sandbox`; runtime now prefers system `bwrap` and falls back to bundled `codex-resources/bwrap`. - Add bundled SHA-256 verification with missing/all-zero digest as the dev-mode skip value, then exec the verified file through `/proc/self/fd`. - Keep `launcher.rs` focused on choosing and dispatching the preferred launcher. Bundled lookup, digest verification, and bundled exec now live in `linux-sandbox/src/bundled_bwrap.rs`; Bazel runfiles lookup lives in `linux-sandbox/src/bazel_bwrap.rs`; shared argv/fd exec helpers live in `linux-sandbox/src/exec_util.rs`. - Teach Bazel tests to surface the Bazel-built `//codex-rs/bwrap:bwrap` through `CARGO_BIN_EXE_bwrap`; `codex-linux-sandbox` only honors that fallback in debug Bazel runfiles environments so release/user runtime lookup stays tied to `codex-resources/bwrap`. - Allow `codex-exec-server` filesystem helpers to preserve just the Bazel bwrap/runfiles variables they need in debug Bazel builds, since those helpers intentionally rebuild a small environment before spawning `codex-linux-sandbox`. - Verify the Bazel bwrap target in Linux release CI with a build-only check. Running `bwrap --version` is too strong for GitHub runners because bubblewrap still attempts namespace setup there. **Verification** - Latest update: `cargo test -p codex-linux-sandbox` - Latest update: `just fix -p codex-linux-sandbox` - `cargo check --target x86_64-unknown-linux-gnu -p codex-linux-sandbox` could not run locally because this macOS machine does not have `x86_64-linux-gnu-gcc`; GitHub Linux Bazel CI is expected to cover the Linux-only modules. - Earlier in this PR: `cargo test -p codex-bwrap` - Earlier in this PR: `cargo test -p codex-exec-server` - Earlier in this PR: `cargo check --release -p codex-exec-server` - Earlier in this PR: `just fix -p codex-linux-sandbox -p codex-exec-server` - Earlier in this PR: `bazel test --nobuild //codex-rs/linux-sandbox:linux-sandbox-all-test //codex-rs/core:core-all-test //codex-rs/exec-server:exec-server-file_system-test //codex-rs/app-server:app-server-all-test` (analysis completed; Bazel then refuses to run tests under `--nobuild`) - Earlier in this PR: `bazel build --nobuild //codex-rs/bwrap:bwrap` - Prior to this update: `just bazel-lock-update`, `just bazel-lock-check`, and YAML parse check for `.github/workflows/bazel.yml` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/21255). * #21257 * #21256 * __->__ #21255 --- .github/workflows/bazel.yml | 16 + codex-rs/Cargo.lock | 12 +- codex-rs/Cargo.toml | 1 + codex-rs/app-server/BUILD.bazel | 3 + codex-rs/bwrap/BUILD.bazel | 35 +++ codex-rs/bwrap/Cargo.toml | 19 ++ codex-rs/bwrap/build.rs | 99 +++++++ codex-rs/bwrap/config.h | 1 + codex-rs/bwrap/src/main.rs | 45 +++ codex-rs/core/BUILD.bazel | 1 + codex-rs/core/README.md | 16 +- codex-rs/exec-server/BUILD.bazel | 3 + codex-rs/exec-server/src/fs_sandbox.rs | 23 +- codex-rs/linux-sandbox/BUILD.bazel | 31 +- codex-rs/linux-sandbox/Cargo.toml | 5 +- codex-rs/linux-sandbox/README.md | 10 +- codex-rs/linux-sandbox/build.rs | 110 +------ codex-rs/linux-sandbox/config.h | 3 - codex-rs/linux-sandbox/src/bazel_bwrap.rs | 68 +++++ codex-rs/linux-sandbox/src/bundled_bwrap.rs | 276 ++++++++++++++++++ codex-rs/linux-sandbox/src/exec_util.rs | 77 +++++ codex-rs/linux-sandbox/src/launcher.rs | 141 +++------ codex-rs/linux-sandbox/src/lib.rs | 8 +- codex-rs/linux-sandbox/src/vendored_bwrap.rs | 78 ----- .../linux-sandbox/tests/suite/landlock.rs | 2 +- .../tests/suite/managed_proxy.rs | 4 +- codex-rs/sandboxing/src/bwrap.rs | 2 +- 27 files changed, 751 insertions(+), 338 deletions(-) create mode 100644 codex-rs/bwrap/BUILD.bazel create mode 100644 codex-rs/bwrap/Cargo.toml create mode 100644 codex-rs/bwrap/build.rs create mode 100644 codex-rs/bwrap/config.h create mode 100644 codex-rs/bwrap/src/main.rs delete mode 100644 codex-rs/linux-sandbox/config.h create mode 100644 codex-rs/linux-sandbox/src/bazel_bwrap.rs create mode 100644 codex-rs/linux-sandbox/src/bundled_bwrap.rs create mode 100644 codex-rs/linux-sandbox/src/exec_util.rs delete mode 100644 codex-rs/linux-sandbox/src/vendored_bwrap.rs diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index aed3f7a1b3..79b49ddb10 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -371,6 +371,22 @@ jobs: -- \ "${bazel_targets[@]}" + - name: Verify Bazel builds bwrap + if: runner.os == 'Linux' + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + ./.github/scripts/run-bazel-ci.sh \ + --remote-download-toplevel \ + --print-failed-action-summary \ + -- \ + build \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + --build_metadata=TAG_job=verify-bwrap \ + -- \ + //codex-rs/bwrap:bwrap + - name: Upload Bazel execution logs if: always() && !cancelled() continue-on-error: true diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cd3e93c9cb..1b92b5afd0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2125,6 +2125,15 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-bwrap" +version = "0.0.0" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -2905,7 +2914,6 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ - "cc", "clap", "codex-core", "codex-process-hardening", @@ -2915,11 +2923,11 @@ dependencies = [ "globset", "landlock", "libc", - "pkg-config", "pretty_assertions", "seccompiler", "serde", "serde_json", + "sha2", "tempfile", "tokio", "url", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ce3e91626d..a58655ed20 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "bwrap", "ansi-escape", "async-utils", "app-server", diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index b7ff5b1695..6765141bdc 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -14,5 +14,8 @@ codex_rust_crate( "app-server-all-test": 16, "app-server-unit-tests": 8, }, + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/bwrap/BUILD.bazel b/codex-rs/bwrap/BUILD.bazel new file mode 100644 index 0000000000..3d0b89b966 --- /dev/null +++ b/codex-rs/bwrap/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "bwrap", + crate_name = "codex_bwrap", + # Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets + # bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds. + build_script_enabled = False, + deps_extra = select({ + "@platforms//os:linux": [":bwrap-ffi"], + "//conditions:default": [], + }), + rustc_flags_extra = select({ + "@platforms//os:linux": ["--cfg=bwrap_available"], + "//conditions:default": [], + }), +) + +cc_library( + name = "bwrap-ffi", + srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], + hdrs = [ + "config.h", + "//codex-rs/vendor:bubblewrap_headers", + ], + copts = [ + "-D_GNU_SOURCE", + "-Dmain=bwrap_main", + ], + includes = ["."], + deps = ["@libcap//:libcap"], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], +) diff --git a/codex-rs/bwrap/Cargo.toml b/codex-rs/bwrap/Cargo.toml new file mode 100644 index 0000000000..ed7010c8fd --- /dev/null +++ b/codex-rs/bwrap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-bwrap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "bwrap" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs new file mode 100644 index 0000000000..40c2712839 --- /dev/null +++ b/codex-rs/bwrap/build.rs @@ -0,0 +1,99 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); + println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] { + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join(source).display() + ); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() { + return; + } + + if let Err(err) = try_build_bwrap() { + panic!("failed to compile bubblewrap for Linux target: {err}"); + } +} + +fn try_build_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + let libcap = pkg_config::Config::new() + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built for Codex" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so the Rust wrapper can expose the Cargo-built binary. + .define("main", Some("bwrap_main")); + for include_path in libcap.include_paths { + // Use -idirafter so target sysroot headers win (musl cross builds), + // while still allowing libcap headers from the host toolchain. + build.flag(format!("-idirafter{}", include_path.display())); + } + + build.compile("standalone_bwrap"); + println!("cargo:rustc-cfg=bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/codex-rs/bwrap/config.h b/codex-rs/bwrap/config.h new file mode 100644 index 0000000000..f73932a0f8 --- /dev/null +++ b/codex-rs/bwrap/config.h @@ -0,0 +1 @@ +#define PACKAGE_STRING "bubblewrap built for Codex" diff --git a/codex-rs/bwrap/src/main.rs b/codex-rs/bwrap/src/main.rs new file mode 100644 index 0000000000..09c624aa9e --- /dev/null +++ b/codex-rs/bwrap/src/main.rs @@ -0,0 +1,45 @@ +#[cfg(all(target_os = "linux", bwrap_available))] +fn main() { + use std::ffi::CStr; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + let cstrings = std::env::args_os() + .map(|arg| { + CString::new(arg.as_os_str().as_bytes()) + .unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}")) + }) + .collect::>(); + let mut argv_ptrs = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect::>(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers remain + // valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); +} + +#[cfg(all(target_os = "linux", not(bwrap_available)))] +fn main() { + panic!( + r#"bubblewrap is not available in this build. +Notes: +- ensure the target OS is Linux +- libcap headers must be available via pkg-config +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# + ); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + panic!("bwrap is only supported on Linux"); +} diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index dbca9ab63a..c78750576b 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -52,6 +52,7 @@ codex_rust_crate( test_tags = ["no-sandbox"], unit_test_timeout = "long", extra_binaries = [ + "//codex-rs/bwrap:bwrap", "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", "//codex-rs/rmcp-client:test_streamable_http_server", diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index be222a1673..3283ba2c3e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -39,14 +39,14 @@ The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If -`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into -the binary and Codex surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. Codex also surfaces -a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the -normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing -because it cannot create the required user namespaces, so Codex rejects -sandboxed shell commands that would enter the bubblewrap path before invoking -`bwrap`. +`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap` +binary shipped with Codex and Codex surfaces a startup warning through its +normal notification path instead of printing directly from the sandbox helper. +Codex also surfaces a startup warning when bubblewrap cannot create user +namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported +for bubblewrap sandboxing because it cannot create the required user +namespaces, so Codex rejects sandboxed shell commands that would enter the +bubblewrap path before invoking `bwrap`. ### Windows diff --git a/codex-rs/exec-server/BUILD.bazel b/codex-rs/exec-server/BUILD.bazel index 57ebe041f8..5f3efe6211 100644 --- a/codex-rs/exec-server/BUILD.bazel +++ b/codex-rs/exec-server/BUILD.bazel @@ -7,5 +7,8 @@ codex_rust_crate( # they install process-global test-binary dispatch state, and the remote # exec-server cases already rely on serialization around the full CLI path. integration_test_args = ["--test-threads=1"], + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 8f084a50e9..76b0f22b2b 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -29,6 +29,15 @@ use crate::rpc::internal_error; use crate::rpc::invalid_request; const FS_HELPER_ENV_ALLOWLIST: &[&str] = &["PATH", "TMPDIR", "TMP", "TEMP"]; +#[cfg(debug_assertions)] +const FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST: &[&str] = &[ + "CARGO_BIN_EXE_bwrap", + "RUNFILES_DIR", + "RUNFILES_MANIFEST_FILE", + "RUNFILES_MANIFEST_ONLY", + "TEST_SRCDIR", + "TEST_WORKSPACE", +]; #[derive(Clone, Debug)] pub(crate) struct FileSystemSandboxRunner { @@ -220,7 +229,19 @@ fn helper_env_from_vars( } fn helper_env_key_is_allowed(key: &str) -> bool { - FS_HELPER_ENV_ALLOWLIST.contains(&key) || (cfg!(windows) && key.eq_ignore_ascii_case("PATH")) + FS_HELPER_ENV_ALLOWLIST.contains(&key) + || bazel_bwrap_env_key_is_allowed(key) + || (cfg!(windows) && key.eq_ignore_ascii_case("PATH")) +} + +#[cfg(debug_assertions)] +fn bazel_bwrap_env_key_is_allowed(key: &str) -> bool { + option_env!("BAZEL_PACKAGE").is_some() && FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST.contains(&key) +} + +#[cfg(not(debug_assertions))] +fn bazel_bwrap_env_key_is_allowed(_key: &str) -> bool { + false } async fn run_command( diff --git a/codex-rs/linux-sandbox/BUILD.bazel b/codex-rs/linux-sandbox/BUILD.bazel index 87ca8ba066..2770b97e09 100644 --- a/codex-rs/linux-sandbox/BUILD.bazel +++ b/codex-rs/linux-sandbox/BUILD.bazel @@ -1,36 +1,9 @@ -load("@rules_cc//cc:defs.bzl", "cc_library") load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "linux-sandbox", crate_name = "codex_linux_sandbox", - # Bazel wires vendored bubblewrap + libcap via :vendored-bwrap-ffi below - # and sets vendored_bwrap_available explicitly, so we skip Cargo's - # build.rs in Bazel builds. - build_script_enabled = False, - deps_extra = select({ - "@platforms//os:linux": [":vendored-bwrap-ffi"], - "//conditions:default": [], - }), - rustc_flags_extra = select({ - "@platforms//os:linux": ["--cfg=vendored_bwrap_available"], - "//conditions:default": [], - }), -) - -cc_library( - name = "vendored-bwrap-ffi", - srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], - hdrs = [ - "config.h", - "//codex-rs/vendor:bubblewrap_headers", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", ], - copts = [ - "-D_GNU_SOURCE", - "-Dmain=bwrap_main", - ], - includes = ["."], - deps = ["@libcap//:libcap"], - target_compatible_with = ["@platforms//os:linux"], - visibility = ["//visibility:private"], ) diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 05967661e2..aaacee27fa 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -27,6 +27,7 @@ libc = { workspace = true } seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] @@ -40,7 +41,3 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } - -[build-dependencies] -cc = "1" -pkg-config = "0.3" diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 5745f4816c..1b4c1e5aa7 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -12,10 +12,10 @@ outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If `bwrap` is missing, -the helper falls back to the vendored bubblewrap path compiled into this -binary. +the helper falls back to the bundled `codex-resources/bwrap` binary shipped +with Codex. Codex also surfaces a startup warning when `bwrap` is missing so users know it -is falling back to the vendored helper. Codex surfaces the same startup warning +is falling back to the bundled helper. Codex surfaces the same startup warning path when bubblewrap cannot create user namespaces. WSL2 follows the normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing because it cannot create the required user namespaces, so Codex rejects sandboxed shell @@ -28,8 +28,8 @@ commands that would enter the bubblewrap path. helper uses it. - If `bwrap` is present but too old to support `--argv0`, the helper uses a no-`--argv0` compatibility path for the inner re-exec. -- If `bwrap` is missing, the helper falls back to the vendored bubblewrap - path. +- If `bwrap` is missing, the helper falls back to the bundled + `codex-resources/bwrap` path. - If `bwrap` is missing, Codex also surfaces a startup warning instead of printing directly from the sandbox helper. - If bubblewrap cannot create user namespaces, Codex surfaces a startup warning diff --git a/codex-rs/linux-sandbox/build.rs b/codex-rs/linux-sandbox/build.rs index a2b4ca86ca..968cfc7e67 100644 --- a/codex-rs/linux-sandbox/build.rs +++ b/codex-rs/linux-sandbox/build.rs @@ -1,111 +1,3 @@ -use std::env; -use std::path::Path; -use std::path::PathBuf; - fn main() { - // Tell rustc/clippy that this is an expected cfg value. - println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)"); - println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); - println!("cargo:rerun-if-env-changed=CODEX_SKIP_VENDORED_BWRAP"); - - // Rebuild if the vendored bwrap sources change. - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bubblewrap.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bind-mount.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("network.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("utils.c").display() - ); - - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "linux" || env::var_os("CODEX_SKIP_VENDORED_BWRAP").is_some() { - return; - } - - if let Err(err) = try_build_vendored_bwrap() { - panic!("failed to compile vendored bubblewrap for Linux target: {err}"); - } -} - -fn try_build_vendored_bwrap() -> Result<(), String> { - let manifest_dir = - PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); - let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); - let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; - let libcap = pkg_config::Config::new() - .probe("libcap") - .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; - - let config_h = out_dir.join("config.h"); - std::fs::write( - &config_h, - r#"#pragma once -#define PACKAGE_STRING "bubblewrap built at codex build-time" -"#, - ) - .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; - - let mut build = cc::Build::new(); - build - .file(src_dir.join("bubblewrap.c")) - .file(src_dir.join("bind-mount.c")) - .file(src_dir.join("network.c")) - .file(src_dir.join("utils.c")) - .include(&out_dir) - .include(&src_dir) - .define("_GNU_SOURCE", None) - // Rename `main` so we can call it via FFI. - .define("main", Some("bwrap_main")); - for include_path in libcap.include_paths { - // Use -idirafter so target sysroot headers win (musl cross builds), - // while still allowing libcap headers from the host toolchain. - build.flag(format!("-idirafter{}", include_path.display())); - } - - build.compile("build_time_bwrap"); - println!("cargo:rustc-cfg=vendored_bwrap_available"); - Ok(()) -} - -/// Resolve the bubblewrap source directory used for build-time compilation. -/// -/// Priority: -/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. -/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. -fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { - if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { - let src_dir = PathBuf::from(path); - if src_dir.exists() { - return Ok(src_dir); - } - return Err(format!( - "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", - src_dir.display() - )); - } - - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - if vendor_dir.exists() { - return Ok(vendor_dir); - } - - Err(format!( - "expected vendored bubblewrap at {}, but it was not found.\n\ -Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", - vendor_dir.display() - )) + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SHA256"); } diff --git a/codex-rs/linux-sandbox/config.h b/codex-rs/linux-sandbox/config.h deleted file mode 100644 index f08aa6fcee..0000000000 --- a/codex-rs/linux-sandbox/config.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -#define PACKAGE_STRING "bubblewrap built at codex build-time" diff --git a/codex-rs/linux-sandbox/src/bazel_bwrap.rs b/codex-rs/linux-sandbox/src/bazel_bwrap.rs new file mode 100644 index 0000000000..90e41c3849 --- /dev/null +++ b/codex-rs/linux-sandbox/src/bazel_bwrap.rs @@ -0,0 +1,68 @@ +#[cfg(debug_assertions)] +use std::fs::File; +#[cfg(debug_assertions)] +use std::io::BufRead; +use std::path::PathBuf; + +#[cfg(debug_assertions)] +const BAZEL_BWRAP_ENV_VAR: &str = "CARGO_BIN_EXE_bwrap"; + +#[cfg(debug_assertions)] +pub(crate) fn candidate() -> Option { + if option_env!("BAZEL_PACKAGE").is_none() || !runfiles_env_present() { + return None; + } + + let raw = PathBuf::from(std::env::var_os(BAZEL_BWRAP_ENV_VAR)?); + if raw.is_absolute() { + return Some(raw); + } + resolve_runfile(raw.to_str()?) +} + +#[cfg(not(debug_assertions))] +pub(crate) fn candidate() -> Option { + None +} + +#[cfg(debug_assertions)] +fn runfiles_env_present() -> bool { + std::env::var_os("RUNFILES_DIR").is_some() + || std::env::var_os("TEST_SRCDIR").is_some() + || std::env::var_os("RUNFILES_MANIFEST_FILE").is_some() +} + +#[cfg(debug_assertions)] +fn resolve_runfile(logical_path: &str) -> Option { + let mut logical_paths = vec![logical_path.to_string()]; + if let Ok(workspace) = std::env::var("TEST_WORKSPACE") + && !workspace.is_empty() + { + logical_paths.push(format!("{workspace}/{logical_path}")); + } + + for root_env in ["RUNFILES_DIR", "TEST_SRCDIR"] { + let Some(root) = std::env::var_os(root_env) else { + continue; + }; + let root = PathBuf::from(root); + for logical_path in &logical_paths { + let candidate = root.join(logical_path); + if candidate.exists() { + return Some(candidate); + } + } + } + + let manifest = PathBuf::from(std::env::var_os("RUNFILES_MANIFEST_FILE")?); + let file = File::open(manifest).ok()?; + for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + if logical_paths.iter().any(|logical_path| logical_path == key) { + return Some(PathBuf::from(value)); + } + } + None +} diff --git a/codex-rs/linux-sandbox/src/bundled_bwrap.rs b/codex-rs/linux-sandbox/src/bundled_bwrap.rs new file mode 100644 index 0000000000..505377907f --- /dev/null +++ b/codex-rs/linux-sandbox/src/bundled_bwrap.rs @@ -0,0 +1,276 @@ +use std::ffi::CStr; +use std::ffi::CString; +use std::fs::File; +use std::io::Read; +use std::os::fd::AsRawFd; +use std::os::raw::c_char; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; + +use crate::bazel_bwrap; +use crate::exec_util::argv_to_cstrings; +use crate::exec_util::make_files_inheritable; +use codex_utils_absolute_path::AbsolutePathBuf; +use sha2::Digest as _; +use sha2::Sha256; + +const SHA256_HEX_LEN: usize = 64; +const NULL_SHA256_DIGEST: [u8; 32] = [0; 32]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BundledBwrapLauncher { + program: AbsolutePathBuf, +} + +pub(crate) fn launcher() -> Option { + let current_exe = std::env::current_exe().ok()?; + find_for_exe(¤t_exe).map(|program| BundledBwrapLauncher { program }) +} + +impl BundledBwrapLauncher { + pub(crate) fn exec(&self, argv: Vec, preserved_files: Vec) -> ! { + let bwrap_file = File::open(self.program.as_path()).unwrap_or_else(|err| { + panic!( + "failed to open bundled bubblewrap {}: {err}", + self.program.as_path().display() + ) + }); + verify_digest(&bwrap_file, expected_sha256(), self.program.as_path()) + .unwrap_or_else(|err| panic!("{err}")); + + make_files_inheritable(&preserved_files); + + let fd_path = format!("/proc/self/fd/{}", bwrap_file.as_raw_fd()); + let program_cstring = CString::new(fd_path.as_str()) + .unwrap_or_else(|err| panic!("invalid bundled bubblewrap fd path: {err}")); + let cstrings = argv_to_cstrings(&argv); + let mut argv_ptrs: Vec<*const c_char> = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: `program_cstring` and every entry in `argv_ptrs` are valid C + // strings for the duration of the call. On success `execv` does not return. + unsafe { + libc::execv(program_cstring.as_ptr(), argv_ptrs.as_ptr()); + } + let err = std::io::Error::last_os_error(); + panic!( + "failed to exec bundled bubblewrap {} via {fd_path}: {err}", + self.program.as_path().display() + ); + } +} + +fn find_for_exe(exe: &Path) -> Option { + candidates_for_exe(exe) + .into_iter() + .find(|candidate| is_executable_file(candidate)) + .map(|path| { + AbsolutePathBuf::from_absolute_path(&path).unwrap_or_else(|err| { + panic!( + "failed to normalize bundled bubblewrap path {}: {err}", + path.display() + ) + }) + }) +} + +fn candidates_for_exe(exe: &Path) -> Vec { + let Some(exe_dir) = exe.parent() else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + candidates.push(exe_dir.join("codex-resources").join("bwrap")); + if let Some(package_target_dir) = exe_dir.parent() { + candidates.push(package_target_dir.join("codex-resources").join("bwrap")); + } + candidates.push(exe_dir.join("bwrap")); + if let Some(path) = bazel_bwrap::candidate() { + candidates.push(path); + } + candidates +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +fn expected_sha256() -> Option<[u8; 32]> { + static EXPECTED: OnceLock> = OnceLock::new(); + *EXPECTED.get_or_init(|| { + let raw_digest = option_env!("CODEX_BWRAP_SHA256")?; + let digest = parse_sha256_hex(raw_digest) + .unwrap_or_else(|err| panic!("invalid CODEX_BWRAP_SHA256 value: {err}")); + (digest != NULL_SHA256_DIGEST).then_some(digest) + }) +} + +fn verify_digest(file: &File, expected: Option<[u8; 32]>, path: &Path) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + + let mut file = file + .try_clone() + .map_err(|err| format!("failed to clone bundled bubblewrap fd: {err}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer).map_err(|err| { + format!( + "failed to read bundled bubblewrap {} for digest verification: {err}", + path.display() + ) + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let actual: [u8; 32] = hasher.finalize().into(); + if actual == expected { + return Ok(()); + } + + Err(format!( + "bundled bubblewrap digest mismatch for {}: expected sha256:{}, got sha256:{}", + path.display(), + bytes_to_hex(&expected), + bytes_to_hex(&actual), + )) +} + +fn parse_sha256_hex(raw: &str) -> Result<[u8; 32], String> { + if raw.len() != SHA256_HEX_LEN { + return Err(format!( + "expected {SHA256_HEX_LEN} hex characters, got {}", + raw.len() + )); + } + + let mut digest = [0_u8; 32]; + for (index, byte) in digest.iter_mut().enumerate() { + let start = index * 2; + *byte = u8::from_str_radix(&raw[start..start + 2], 16) + .map_err(|err| format!("invalid hex byte at offset {start}: {err}"))?; + } + Ok(digest) +} + +fn bytes_to_hex(bytes: &[u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut hex = String::with_capacity(SHA256_HEX_LEN); + for byte in bytes { + hex.push(HEX[(byte >> 4) as usize] as char); + hex.push(HEX[(byte & 0x0f) as usize] as char); + } + hex +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::NamedTempFile; + use tempfile::tempdir; + + #[test] + fn finds_standalone_bundled_bwrap_next_to_exe_resources() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_npm_bundled_bwrap_next_to_target_vendor_dir() { + let temp_dir = tempdir().expect("temp dir"); + let target_dir = temp_dir.path().join("vendor/x86_64-unknown-linux-musl"); + let exe = target_dir.join("codex").join("codex"); + let expected_bwrap = target_dir.join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_adjacent_dev_bwrap() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn digest_verification_skips_missing_expected_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + verify_digest(file.as_file(), /*expected*/ None, file.path()) + .expect("missing digest should skip verification"); + } + + #[test] + fn digest_verification_accepts_matching_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + let expected: [u8; 32] = Sha256::digest(b"contents").into(); + + verify_digest(file.as_file(), Some(expected), file.path()) + .expect("matching digest should verify"); + } + + #[test] + fn digest_verification_rejects_mismatched_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + let err = verify_digest(file.as_file(), Some([0xab; 32]), file.path()) + .expect_err("mismatched digest should fail"); + assert!(err.contains("bundled bubblewrap digest mismatch")); + } + + #[test] + fn parses_sha256_hex_digest() { + assert_eq!(parse_sha256_hex(&"ab".repeat(32)), Ok([0xab; 32])); + assert_eq!(parse_sha256_hex(&"00".repeat(32)), Ok(NULL_SHA256_DIGEST)); + assert!(parse_sha256_hex("ab").is_err()); + assert!(parse_sha256_hex(&format!("{}xx", "00".repeat(31))).is_err()); + } + + fn write_executable(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(path, b"").expect("write executable"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("set executable permissions"); + } +} diff --git a/codex-rs/linux-sandbox/src/exec_util.rs b/codex-rs/linux-sandbox/src/exec_util.rs new file mode 100644 index 0000000000..594c7a725d --- /dev/null +++ b/codex-rs/linux-sandbox/src/exec_util.rs @@ -0,0 +1,77 @@ +use std::ffi::CString; +use std::fs::File; +use std::os::fd::AsRawFd; + +pub(crate) fn argv_to_cstrings(argv: &[String]) -> Vec { + let mut cstrings: Vec = Vec::with_capacity(argv.len()); + for arg in argv { + match CString::new(arg.as_str()) { + Ok(value) => cstrings.push(value), + Err(err) => panic!("failed to convert argv to CString: {err}"), + } + } + cstrings +} + +pub(crate) fn make_files_inheritable(files: &[File]) { + for file in files { + clear_cloexec(file.as_raw_fd()); + } +} + +fn clear_cloexec(fd: libc::c_int) { + // SAFETY: `fd` is an owned descriptor kept alive by `files`. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to read fd flags for preserved bubblewrap file descriptor {fd}: {err}"); + } + let cleared_flags = flags & !libc::FD_CLOEXEC; + if cleared_flags == flags { + return; + } + + // SAFETY: `fd` is valid and we are only clearing FD_CLOEXEC. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, cleared_flags) }; + if result < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to clear CLOEXEC for preserved bubblewrap file descriptor {fd}: {err}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::NamedTempFile; + + #[test] + fn preserved_files_are_made_inheritable() { + let file = NamedTempFile::new().expect("temp file"); + set_cloexec(file.as_file().as_raw_fd()); + + make_files_inheritable(std::slice::from_ref(file.as_file())); + + assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0); + } + + fn set_cloexec(fd: libc::c_int) { + let flags = fd_flags(fd); + // SAFETY: `fd` is valid for the duration of the test. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to set CLOEXEC for test fd {fd}: {err}"); + } + } + + fn fd_flags(fd: libc::c_int) -> libc::c_int { + // SAFETY: `fd` is valid for the duration of the test. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + let err = std::io::Error::last_os_error(); + panic!("failed to read fd flags for test fd {fd}: {err}"); + } + flags + } +} diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 577ef52405..9b80075531 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -1,20 +1,24 @@ +use std::ffi::CStr; use std::ffi::CString; use std::fs::File; -use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::process::Command; use std::sync::OnceLock; -use crate::vendored_bwrap::exec_vendored_bwrap; +use crate::bundled_bwrap; +use crate::bundled_bwrap::BundledBwrapLauncher; +use crate::exec_util::argv_to_cstrings; +use crate::exec_util::make_files_inheritable; use codex_sandboxing::find_system_bwrap_in_path; use codex_utils_absolute_path::AbsolutePathBuf; #[derive(Debug, Clone, PartialEq, Eq)] enum BubblewrapLauncher { System(SystemBwrapLauncher), - Vendored, + Bundled(BundledBwrapLauncher), + Unavailable, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -34,30 +38,44 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { BubblewrapLauncher::System(launcher) => { exec_system_bwrap(&launcher.program, argv, preserved_files) } - BubblewrapLauncher::Vendored => exec_vendored_bwrap(argv, preserved_files), + BubblewrapLauncher::Bundled(launcher) => launcher.exec(argv, preserved_files), + BubblewrapLauncher::Unavailable => { + panic!( + "bubblewrap is unavailable: no system bwrap was found on PATH and no bundled \ + codex-resources/bwrap binary was found next to the Codex executable" + ) + } } } fn preferred_bwrap_launcher() -> BubblewrapLauncher { static LAUNCHER: OnceLock = OnceLock::new(); LAUNCHER - .get_or_init(|| match find_system_bwrap_in_path() { - Some(path) => preferred_bwrap_launcher_for_path(&path), - None => BubblewrapLauncher::Vendored, + .get_or_init(|| { + if let Some(path) = find_system_bwrap_in_path() + && let Some(launcher) = system_bwrap_launcher_for_path(&path) + { + return BubblewrapLauncher::System(launcher); + } + + match bundled_bwrap::launcher() { + Some(launcher) => BubblewrapLauncher::Bundled(launcher), + None => BubblewrapLauncher::Unavailable, + } }) .clone() } -fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { - preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) +fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option { + system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) } -fn preferred_bwrap_launcher_for_path_with_probe( +fn system_bwrap_launcher_for_path_with_probe( system_bwrap_path: &Path, system_bwrap_capabilities: impl FnOnce(&Path) -> Option, -) -> BubblewrapLauncher { +) -> Option { if !system_bwrap_path.is_file() { - return BubblewrapLauncher::Vendored; + return None; } let Some(SystemBwrapCapabilities { @@ -65,7 +83,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( supports_perms: true, }) = system_bwrap_capabilities(system_bwrap_path) else { - return BubblewrapLauncher::Vendored; + return None; }; let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, @@ -74,7 +92,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( system_bwrap_path.display() ), }; - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: system_bwrap_path, supports_argv0, }) @@ -83,7 +101,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( pub(crate) fn preferred_bwrap_supports_argv0() -> bool { match preferred_bwrap_launcher() { BubblewrapLauncher::System(launcher) => launcher.supports_argv0, - BubblewrapLauncher::Vendored => true, + BubblewrapLauncher::Bundled(_) | BubblewrapLauncher::Unavailable => true, } } @@ -117,7 +135,11 @@ fn exec_system_bwrap( let program = CString::new(program.as_path().as_os_str().as_bytes()) .unwrap_or_else(|err| panic!("invalid system bubblewrap path: {err}")); let cstrings = argv_to_cstrings(&argv); - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); + let mut argv_ptrs: Vec<*const c_char> = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect(); argv_ptrs.push(std::ptr::null()); // SAFETY: `program` and every entry in `argv_ptrs` are valid C strings for @@ -129,43 +151,6 @@ fn exec_system_bwrap( panic!("failed to exec system bubblewrap {program_path}: {err}"); } -fn argv_to_cstrings(argv: &[String]) -> Vec { - let mut cstrings: Vec = Vec::with_capacity(argv.len()); - for arg in argv { - match CString::new(arg.as_str()) { - Ok(value) => cstrings.push(value), - Err(err) => panic!("failed to convert argv to CString: {err}"), - } - } - cstrings -} - -fn make_files_inheritable(files: &[File]) { - for file in files { - clear_cloexec(file.as_raw_fd()); - } -} - -fn clear_cloexec(fd: libc::c_int) { - // SAFETY: `fd` is an owned descriptor kept alive by `files`. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - if flags < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to read fd flags for preserved bubblewrap file descriptor {fd}: {err}"); - } - let cleared_flags = flags & !libc::FD_CLOEXEC; - if cleared_flags == flags { - return; - } - - // SAFETY: `fd` is valid and we are only clearing FD_CLOEXEC. - let result = unsafe { libc::fcntl(fd, libc::F_SETFD, cleared_flags) }; - if result < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to clear CLOEXEC for preserved bubblewrap file descriptor {fd}: {err}"); - } -} - #[cfg(test)] mod tests { use super::*; @@ -179,13 +164,13 @@ mod tests { let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: true, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: expected, supports_argv0: true, }) @@ -198,13 +183,13 @@ mod tests { let fake_bwrap_path = fake_bwrap.path(); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"), supports_argv0: false, }) @@ -212,55 +197,25 @@ mod tests { } #[test] - fn falls_back_to_vendored_when_system_bwrap_lacks_perms() { + fn ignores_system_bwrap_when_system_bwrap_lacks_perms() { let fake_bwrap = NamedTempFile::new().expect("temp file"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: false, }) }), - BubblewrapLauncher::Vendored + None ); } #[test] - fn falls_back_to_vendored_when_system_bwrap_is_missing() { + fn ignores_system_bwrap_when_system_bwrap_is_missing() { assert_eq!( - preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), - BubblewrapLauncher::Vendored + system_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + None ); } - - #[test] - fn preserved_files_are_made_inheritable_for_system_exec() { - let file = NamedTempFile::new().expect("temp file"); - set_cloexec(file.as_file().as_raw_fd()); - - make_files_inheritable(std::slice::from_ref(file.as_file())); - - assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0); - } - - fn set_cloexec(fd: libc::c_int) { - let flags = fd_flags(fd); - // SAFETY: `fd` is valid for the duration of the test. - let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; - if result < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to set CLOEXEC for test fd {fd}: {err}"); - } - } - - fn fd_flags(fd: libc::c_int) -> libc::c_int { - // SAFETY: `fd` is valid for the duration of the test. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - if flags < 0 { - let err = std::io::Error::last_os_error(); - panic!("failed to read fd flags for test fd {fd}: {err}"); - } - flags - } } diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 900287c99d..478cd6c379 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -4,8 +4,14 @@ //! - in-process restrictions (`no_new_privs` + seccomp), and //! - bubblewrap for filesystem isolation. #[cfg(target_os = "linux")] +mod bazel_bwrap; +#[cfg(target_os = "linux")] +mod bundled_bwrap; +#[cfg(target_os = "linux")] mod bwrap; #[cfg(target_os = "linux")] +mod exec_util; +#[cfg(target_os = "linux")] mod landlock; #[cfg(target_os = "linux")] mod launcher; @@ -13,8 +19,6 @@ mod launcher; mod linux_run_main; #[cfg(target_os = "linux")] mod proxy_routing; -#[cfg(target_os = "linux")] -mod vendored_bwrap; #[cfg(target_os = "linux")] pub fn run_main() -> ! { diff --git a/codex-rs/linux-sandbox/src/vendored_bwrap.rs b/codex-rs/linux-sandbox/src/vendored_bwrap.rs deleted file mode 100644 index a2da14db05..0000000000 --- a/codex-rs/linux-sandbox/src/vendored_bwrap.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Build-time bubblewrap entrypoint. -//! -//! On Linux targets, the build script compiles bubblewrap's C sources and -//! exposes a `bwrap_main` symbol that we can call via FFI. - -#[cfg(vendored_bwrap_available)] -mod imp { - use std::ffi::CString; - use std::fs::File; - use std::os::raw::c_char; - - unsafe extern "C" { - fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; - } - - fn argv_to_cstrings(argv: &[String]) -> Vec { - let mut cstrings: Vec = Vec::with_capacity(argv.len()); - for arg in argv { - match CString::new(arg.as_str()) { - Ok(value) => cstrings.push(value), - Err(err) => panic!("failed to convert argv to CString: {err}"), - } - } - cstrings - } - - /// Run the build-time bubblewrap `main` function and return its exit code. - /// - /// On success, bubblewrap will `execve` into the target program and this - /// function will never return. A return value therefore implies failure. - pub(crate) fn run_vendored_bwrap_main( - argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - let cstrings = argv_to_cstrings(argv); - - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); - argv_ptrs.push(std::ptr::null()); - - // SAFETY: We provide a null-terminated argv vector whose pointers - // remain valid for the duration of the call. - unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) } - } - - /// Execute the build-time bubblewrap `main` function with the given argv. - pub(crate) fn exec_vendored_bwrap(argv: Vec, preserved_files: Vec) -> ! { - let exit_code = run_vendored_bwrap_main(&argv, &preserved_files); - std::process::exit(exit_code); - } -} - -#[cfg(not(vendored_bwrap_available))] -mod imp { - use std::fs::File; - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn run_vendored_bwrap_main( - _argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - panic!( - r#"build-time bubblewrap is not available in this build. -codex-linux-sandbox should always compile vendored bubblewrap on Linux targets. -Notes: -- ensure the target OS is Linux -- libcap headers must be available via pkg-config -- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# - ); - } - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn exec_vendored_bwrap(_argv: Vec, _preserved_files: Vec) -> ! { - let _ = run_vendored_bwrap_main(&[], &[]); - unreachable!("run_vendored_bwrap_main should always panic in this configuration") - } -} - -pub(crate) use imp::exec_vendored_bwrap; diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 31021fb9ce..87e4ce68ae 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -40,7 +40,7 @@ const NETWORK_TIMEOUT_MS: u64 = 10_000; #[cfg(target_arch = "aarch64")] const NETWORK_TIMEOUT_MS: u64 = 10_000; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; fn create_env_from_core_vars() -> HashMap { let policy = ShellEnvironmentPolicy::default(); diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 932b7981d3..d1aa6856c4 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -15,7 +15,7 @@ use std::process::Stdio; use std::time::Duration; use tokio::process::Command; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const NETWORK_TIMEOUT_MS: u64 = 4_000; const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ "loopback: Failed RTM_NEWADDR", @@ -82,7 +82,7 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool { async fn managed_proxy_skip_reason() -> Option { if should_skip_bwrap_tests().await { - return Some("vendored bwrap was not built in this environment".to_string()); + return Some("bubblewrap is unavailable in this environment".to_string()); } let mut env = create_env_from_core_vars(); diff --git a/codex-rs/sandboxing/src/bwrap.rs b/codex-rs/sandboxing/src/bwrap.rs index 840bb3e62d..e0eee177fb 100644 --- a/codex-rs/sandboxing/src/bwrap.rs +++ b/codex-rs/sandboxing/src/bwrap.rs @@ -18,7 +18,7 @@ const MISSING_BWRAP_WARNING: &str = concat!( "Install bubblewrap with your OS package manager. ", "See the sandbox prerequisites: ", "https://developers.openai.com/codex/concepts/sandboxing#prerequisites. ", - "Codex will use the vendored bubblewrap in the meantime.", + "Codex will use the bundled bubblewrap in the meantime.", ); const USER_NAMESPACE_WARNING: &str = "Codex's Linux sandbox uses bubblewrap and needs access to create user namespaces."; From 3ec18a2c0ad0ee440e868de05403e5d48d91ffab Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 5 May 2026 17:15:46 -0700 Subject: [PATCH 04/12] release: publish standalone bwrap artifacts (#21256) **Summary** - Build Linux `bwrap` before the main release binaries. - Export the release `bwrap` SHA-256 as `CODEX_BWRAP_SHA256` so the Codex binary can verify the bundled fallback. - Sign, stage, and upload `bwrap` alongside the primary Linux release artifacts. **Verification** - YAML parse check for `.github/workflows/rust-release.yml` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/21256). * #21257 * __->__ #21256 --- .github/workflows/rust-release.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index faab87f97b..71aab45394 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -255,6 +255,24 @@ jobs: with: target: ${{ matrix.target }} + - if: ${{ contains(matrix.target, 'linux') }} + name: Build bwrap and export digest + shell: bash + run: | + set -euo pipefail + target="${{ matrix.target }}" + cargo build --target "$target" --release --timings --bin bwrap + + bwrap_path="target/${target}/release/bwrap" + if [[ ! -f "$bwrap_path" ]]; then + echo "bwrap binary ${bwrap_path} not found" + exit 1 + fi + + digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" + echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" + echo "Built bwrap ${bwrap_path} with sha256:${digest}" + - name: Cargo build shell: bash run: | @@ -278,7 +296,7 @@ jobs: with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} + binaries: ${{ matrix.binaries }} bwrap - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) @@ -361,6 +379,12 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + cp "target/${{ matrix.target }}/release/bwrap" "$dest/bwrap-${{ matrix.target }}" + cp "target/${{ matrix.target }}/release/bwrap.sigstore" \ + "$dest/bwrap-${{ matrix.target }}.sigstore" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi From 2c1a361a2e7232b8177ea0ea651dfcc616be895d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 5 May 2026 17:16:06 -0700 Subject: [PATCH 05/12] [codex] Move thread naming to app server (#21260) ## Why Thread names are app-server metadata now, backed by the thread store and sqlite state database. Keeping a core `SetThreadName` op plus a rollout `thread_name_updated` event made rename persistence live in the wrong layer and required historical replay support for an event that new app-server flows should not write. ## What changed - Removed `Op::SetThreadName` and `EventMsg::ThreadNameUpdated` from the core protocol and deleted the core handler path that appended rename events to rollouts. - Updated app-server `thread/name/set` so both loaded and unloaded threads write through thread-store metadata and app-server emits `thread/name/updated` notifications. - Updated local thread-store name metadata updates to write sqlite title metadata and the legacy thread-name index without appending rollout events. - Removed state extraction and rollout handling for the deleted thread-name event. ## Validation - `cargo test -p codex-app-server thread_name_updated_broadcasts` - `cargo test -p codex-app-server thread_name_set_is_reflected_in_read_list_and_resume` - `cargo test -p codex-thread-store update_thread_metadata_sets_name_on_active_rollout_and_indexes_name` - `cargo test -p codex-state` - `cargo check -p codex-mcp-server -p codex-rollout-trace` - `just fix -p codex-app-server -p codex-thread-store -p codex-state -p codex-mcp-server -p codex-rollout-trace` ## Docs No external documentation update is expected for this internal ownership change. --- .../app-server/src/bespoke_event_handling.rs | 12 --- .../external_agent_config_processor.rs | 10 ++- .../request_processors/thread_processor.rs | 13 +-- .../tests/suite/v2/thread_name_websocket.rs | 33 -------- codex-rs/core/src/codex_delegate.rs | 4 - codex-rs/core/src/session/handlers.rs | 79 ------------------- codex-rs/core/src/session/turn.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 3 - codex-rs/protocol/src/protocol.rs | 17 ---- codex-rs/rollout-trace/src/protocol_event.rs | 2 - codex-rs/rollout/src/policy.rs | 1 - codex-rs/state/src/extract.rs | 31 +------- .../src/local/update_thread_metadata.rs | 41 +++++----- 13 files changed, 32 insertions(+), 215 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 55b3fea814..18fcc14091 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -53,7 +53,6 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; use codex_app_server_protocol::ThreadRealtimeErrorNotification; use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; @@ -1207,17 +1206,6 @@ pub(crate) async fn apply_bespoke_event_handling( outgoing.send_response(request_id, response).await; } } - EventMsg::ThreadNameUpdated(thread_name_event) => { - let notification = ThreadNameUpdatedNotification { - thread_id: thread_name_event.thread_id.to_string(), - thread_name: thread_name_event.thread_name, - }; - outgoing - .send_global_server_notification(ServerNotification::ThreadNameUpdated( - notification, - )) - .await; - } EventMsg::ThreadGoalUpdated(thread_goal_event) => { let notification = ThreadGoalUpdatedNotification { thread_id: thread_goal_event.thread_id.to_string(), diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 200d8e89df..ca7f8a9d4c 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -36,7 +36,7 @@ use codex_external_agent_sessions::prepare_validated_session_imports; use codex_external_agent_sessions::record_imported_session; use codex_protocol::ThreadId; use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::Op; +use codex_thread_store::ThreadMetadataPatch; use std::collections::HashSet; use std::path::PathBuf; use tokio::sync::Semaphore; @@ -320,7 +320,13 @@ impl ExternalAgentConfigRequestProcessor { { imported_thread .thread - .submit(Op::SetThreadName { name }) + .update_thread_metadata( + ThreadMetadataPatch { + name: Some(name), + ..Default::default() + }, + /*include_archived*/ false, + ) .await .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; } diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index fd61182788..a9c7ab90d1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -405,10 +405,7 @@ impl ThreadRequestProcessor { request_id: ConnectionRequestId, params: ThreadSetNameParams, ) -> Result, JSONRPCErrorError> { - match self - .thread_set_name_response_inner(&request_id, params) - .await - { + match self.thread_set_name_response_inner(params).await { Ok((response, notification)) => { self.outgoing .send_response(request_id.clone(), response) @@ -1335,7 +1332,6 @@ impl ThreadRequestProcessor { async fn thread_set_name_response_inner( &self, - request_id: &ConnectionRequestId, params: ThreadSetNameParams, ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> { @@ -1347,13 +1343,6 @@ impl ThreadRequestProcessor { }; let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - self.submit_core_op(request_id, thread.as_ref(), Op::SetThreadName { name }) - .await - .map_err(|err| internal_error(format!("failed to set thread name: {err}")))?; - return Ok((ThreadSetNameResponse {}, None)); - } - self.thread_store .update_thread_metadata(StoreUpdateThreadMetadataParams { thread_id, diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index 6626b7a6cc..951e4d74e9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -22,11 +22,7 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_core::find_thread_name_by_id; -use codex_core::find_thread_path_by_id_str; use codex_protocol::ThreadId; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -85,10 +81,6 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -141,10 +133,6 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -206,24 +194,3 @@ async fn assert_legacy_thread_name( ); Ok(()) } - -async fn thread_name_update_rollout_count( - codex_home: &Path, - conversation_id: &str, -) -> Result { - let rollout_path = - find_thread_path_by_id_str(codex_home, conversation_id, /*state_db_ctx*/ None) - .await? - .context("rollout path")?; - let contents = tokio::fs::read_to_string(rollout_path).await?; - Ok(contents - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|line| { - matches!( - line.item, - RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(_)) - ) - }) - .count()) -} diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9578fa5726..6aae36d2e7 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -266,10 +266,6 @@ async fn forward_events( id: _, msg: EventMsg::SessionConfigured(_), } => {} - Event { - id: _, - msg: EventMsg::ThreadNameUpdated(_), - } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 612eaf5d65..6d507e6ffd 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -52,7 +52,6 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SkillErrorInfo; use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::ThreadMemoryMode; -use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -763,21 +762,6 @@ pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32 .await; } -async fn persist_thread_name_update( - sess: &Arc, - event: ThreadNameUpdatedEvent, -) -> anyhow::Result { - let msg = EventMsg::ThreadNameUpdated(event); - let item = RolloutItem::EventMsg(msg.clone()); - let live_thread = sess.live_thread_for_persistence("rename thread")?; - live_thread.persist().await?; - live_thread - .append_items(std::slice::from_ref(&item)) - .await?; - live_thread.flush().await?; - Ok(msg) -} - pub(super) async fn persist_thread_memory_mode_update( sess: &Arc, mode: ThreadMemoryMode, @@ -792,65 +776,6 @@ pub(super) async fn persist_thread_memory_mode_update( Ok(()) } -/// Persists the thread name in the rollout and state database, updates in-memory state, and -/// emits a `ThreadNameUpdated` event on success. -pub async fn set_thread_name(sess: &Arc, sub_id: String, name: String) { - let Some(name) = crate::util::normalize_thread_name(&name) else { - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: "Thread name cannot be empty.".to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }; - sess.send_event_raw(event).await; - return; - }; - - let updated = ThreadNameUpdatedEvent { - thread_id: sess.conversation_id, - thread_name: Some(name.clone()), - }; - - let msg = match persist_thread_name_update(sess, updated).await { - Ok(msg) => msg, - Err(err) => { - warn!("Failed to persist thread name update to rollout: {err}"); - let event = Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }; - sess.send_event_raw(event).await; - return; - } - }; - - if let Some(state_db) = sess.services.state_db.as_deref() - && let Err(err) = state_db - .update_thread_title(sess.conversation_id, &name) - .await - { - warn!("Failed to update thread title in state db: {err}"); - } - - { - let mut state = sess.state.lock().await; - state.session_configuration.thread_name = Some(name.clone()); - } - - let codex_home = sess.codex_home().await; - if let Err(err) = - crate::rollout::append_thread_name(&codex_home, sess.conversation_id, &name).await - { - warn!("Failed to update legacy thread name index: {err}"); - } - - sess.deliver_event_raw(Event { id: sub_id, msg }).await; -} - /// Persists thread-level memory mode metadata for the active session. /// /// This does not involve the model and only affects whether the thread is @@ -1119,10 +1044,6 @@ pub(super) async fn submission_loop( thread_rollback(&sess, sub.id.clone(), num_turns).await; false } - Op::SetThreadName { name } => { - set_thread_name(&sess, sub.id.clone(), name).await; - false - } Op::SetThreadMemoryMode { mode } => { set_thread_memory_mode(&sess, sub.id.clone(), mode).await; false diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8ebbb0a8ac..75f4762c9e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1476,7 +1476,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::AgentReasoningRawContent(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::SessionConfigured(_) - | EventMsg::ThreadNameUpdated(_) | EventMsg::ThreadGoalUpdated(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4070cc7e11..4ec8c51434 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -318,9 +318,6 @@ async fn run_codex_tool_session_inner( EventMsg::SessionConfigured(_) => { tracing::error!("unexpected SessionConfigured event"); } - EventMsg::ThreadNameUpdated(_) => { - // Ignore session metadata updates in MCP tool runner. - } EventMsg::ThreadGoalUpdated(_) => { // Ignore thread goal metadata updates in MCP tool runner. } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3675806e60..ab294ab157 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -766,11 +766,6 @@ pub enum Op { /// to generate a summary which will be returned as an AgentMessage event. Compact, - /// Set a user-facing thread name in the persisted rollout metadata. - /// This is a local-only operation handled by codex-core; it does not - /// involve the model. - SetThreadName { name: String }, - /// Set whether the thread remains eligible for memory generation. /// /// This persists thread-level memory mode metadata without involving the @@ -903,7 +898,6 @@ impl Op { Self::ReloadUserConfig => "reload_user_config", Self::ListSkills { .. } => "list_skills", Self::Compact => "compact", - Self::SetThreadName { .. } => "set_thread_name", Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode", Self::ThreadRollback { .. } => "thread_rollback", Self::Review { .. } => "review", @@ -1373,9 +1367,6 @@ pub enum EventMsg { /// Ack the client's configure message. SessionConfigured(SessionConfiguredEvent), - /// Updated session metadata (e.g., thread name changes). - ThreadNameUpdated(ThreadNameUpdatedEvent), - /// Updated long-running goal metadata for the thread. ThreadGoalUpdated(ThreadGoalUpdatedEvent), @@ -3592,14 +3583,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { } } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ThreadNameUpdatedEvent { - pub thread_id: ThreadId, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "protocol/")] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 542073342e..9132ba91e7 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -236,7 +236,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s EventMsg::TurnStarted(_) => Some("turn_started"), EventMsg::TurnComplete(_) => Some("turn_complete"), EventMsg::TurnAborted(_) => Some("turn_aborted"), - EventMsg::ThreadNameUpdated(_) => Some("thread_name_updated"), EventMsg::ThreadRolledBack(_) => Some("thread_rolled_back"), EventMsg::Error(_) => Some("error"), EventMsg::Warning(_) => Some("warning"), diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index aeb92ade11..6aefff7480 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -98,7 +98,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::AgentReasoningRawContent(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TokenCount(_) - | EventMsg::ThreadNameUpdated(_) | EventMsg::ContextCompacted(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index a4a0ab0f6a..dcb730d6c6 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -33,9 +33,7 @@ pub fn apply_rollout_item( pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { match item { RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true, - RolloutItem::EventMsg( - EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadNameUpdated(_), - ) => true, + RolloutItem::EventMsg(EventMsg::TokenCount(_) | EventMsg::UserMessage(_)) => true, RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) => { false } @@ -97,13 +95,6 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { } } } - EventMsg::ThreadNameUpdated(updated) => { - if let Some(title) = updated.thread_name.as_deref() - && !title.trim().is_empty() - { - metadata.title = title.trim().to_string(); - } - } _ => {} } } @@ -159,7 +150,6 @@ mod tests { use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::UserMessageEvent; @@ -205,25 +195,6 @@ mod tests { assert_eq!(metadata.title, "actual user request"); } - #[test] - fn thread_name_update_replaces_title_without_changing_first_user_message() { - let mut metadata = metadata_for_test(); - metadata.title = "actual user request".to_string(); - metadata.first_user_message = Some("actual user request".to_string()); - let item = RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id: metadata.id, - thread_name: Some("saved-session".to_string()), - })); - - apply_rollout_item(&mut metadata, &item, "test-provider"); - - assert_eq!( - metadata.first_user_message.as_deref(), - Some("actual user request") - ); - assert_eq!(metadata.title, "saved-session"); - } - #[test] fn event_msg_image_only_user_message_sets_image_placeholder_preview() { let mut metadata = metadata_for_test(); diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 2c70c815ed..15a7292124 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -2,11 +2,9 @@ use std::path::Path; use std::path::PathBuf; use codex_protocol::ThreadId; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; -use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; use codex_rollout::append_rollout_item_to_path; use codex_rollout::append_thread_name; @@ -50,10 +48,8 @@ pub(super) async fn update_thread_metadata( } let resolved_rollout_path = resolve_rollout_path(store, thread_id, params.include_archived).await?; + let name = params.patch.name; let git_info = params.patch.git_info; - if let Some(name) = params.patch.name { - apply_thread_name(store, resolved_rollout_path.path.as_path(), thread_id, name).await?; - } if let Some(memory_mode) = params.patch.memory_mode { apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode) .await?; @@ -70,6 +66,10 @@ pub(super) async fn update_thread_metadata( ) .await; + if let Some(name) = name { + apply_thread_name(store, thread_id, name).await?; + } + let resolved_git_info = match git_info { Some(git_info) => { let state_db = store.state_db(); @@ -229,20 +229,22 @@ async fn apply_thread_git_info_to_rollout( async fn apply_thread_name( store: &LocalThreadStore, - rollout_path: &Path, thread_id: ThreadId, name: String, ) -> ThreadStoreResult<()> { - let item = RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { - thread_id, - thread_name: Some(name.clone()), - })); - - append_rollout_item_to_path(rollout_path, &item) + let updated = store + .state_db() + .update_thread_title(thread_id, &name) .await .map_err(|err| ThreadStoreError::Internal { message: format!("failed to set thread name: {err}"), })?; + if !updated { + return Err(ThreadStoreError::Internal { + message: format!("thread metadata unavailable before name update: {thread_id}"), + }); + } + append_thread_name(store.config.codex_home.as_path(), thread_id, &name) .await .map_err(|err| ThreadStoreError::Internal { @@ -369,8 +371,7 @@ mod tests { let store = test_store(home.path()).await; let uuid = Uuid::from_u128(301); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); - let path = - write_session_file(home.path(), "2025-01-03T14-00-00", uuid).expect("session file"); + write_session_file(home.path(), "2025-01-03T14-00-00", uuid).expect("session file"); let thread = store .update_thread_metadata(UpdateThreadMetadataParams { @@ -390,11 +391,13 @@ mod tests { .expect("find thread name"); assert_eq!(latest_name.as_deref(), Some("A sharper name")); - let appended = last_rollout_item(path.as_path()); - assert_eq!(appended["type"], "event_msg"); - assert_eq!(appended["payload"]["type"], "thread_name_updated"); - assert_eq!(appended["payload"]["thread_id"], thread_id.to_string()); - assert_eq!(appended["payload"]["thread_name"], "A sharper name"); + let metadata = store + .state_db() + .get_thread(thread_id) + .await + .expect("get metadata") + .expect("metadata"); + assert_eq!(metadata.title, "A sharper name"); } #[tokio::test] From 794c240f25657033f4b07839cf4e2b8fd0597f1d Mon Sep 17 00:00:00 2001 From: mchen-oai Date: Tue, 5 May 2026 17:37:48 -0700 Subject: [PATCH 06/12] Add model and reasoning effort to MCP turn metadata (#21219) ## Why - Similar change as https://github.com/openai/codex/pull/19473. - Without change: MCP tool calls receive `_meta["x-codex-turn-metadata"]` with `session_id`, `turn_id`, and `turn_started_at_unix_ms`. - Issue: MCP servers may want the model and reasoning effort to better understand tool-call behavior and latency relative to turn start. ## What Changed - With change: MCP turn metadata now includes `model` and `reasoning_effort`, propagated in `_meta["x-codex-turn-metadata"]`. - Normal `/responses` turn metadata headers are unchanged. ## Verification - `codex-rs/core/src/mcp_tool_call_tests.rs` - `codex-rs/core/src/turn_metadata_tests.rs` - `codex-rs/core/tests/suite/search_tool.rs` --- codex-rs/core/src/mcp_tool_call.rs | 9 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 60 +++++++++++++-------- codex-rs/core/src/session/turn_context.rs | 17 +++--- codex-rs/core/src/turn_metadata.rs | 33 ++++++++++-- codex-rs/core/src/turn_metadata_tests.rs | 65 +++++++++++++++++++++++ codex-rs/core/tests/suite/search_tool.rs | 18 ++++++- 6 files changed, 168 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 58f26cb25e..91e2e079ab 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -33,6 +33,7 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::PermissionRequestPayload; +use crate::turn_metadata::McpTurnMetadataContext; use codex_analytics::AppInvocation; use codex_analytics::InvocationType; use codex_analytics::build_track_events_context; @@ -895,7 +896,13 @@ fn build_mcp_tool_call_request_meta( ) -> Option { let mut request_meta = serde_json::Map::new(); - if let Some(turn_metadata) = turn_context.turn_metadata_state.current_meta_value() { + if let Some(turn_metadata) = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(McpTurnMetadataContext { + model: turn_context.model_info.slug.as_str(), + reasoning_effort: turn_context.effective_reasoning_effort(), + }) + { request_meta.insert( crate::X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 3d81f05c72..a6818579a6 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -4,6 +4,7 @@ use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use crate::state::ActiveTurn; use crate::test_support::models_manager_with_provider; +use crate::turn_metadata::McpTurnMetadataContext; use codex_config::CONFIG_TOML_FILE; use codex_config::config_toml::ConfigToml; use codex_config::types::AppConfig; @@ -71,6 +72,13 @@ fn approval_metadata( } } +fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataContext<'_> { + McpTurnMetadataContext { + model: turn_context.model_info.slug.as_str(), + reasoning_effort: turn_context.effective_reasoning_effort(), + } +} + fn write_sample_plugin_mcp(codex_home: &std::path::Path) { let plugin_root = codex_home.join("plugins/cache/test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); @@ -920,13 +928,10 @@ fn truncate_mcp_tool_result_for_event_bounds_large_error() { #[tokio::test] async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); let meta = build_mcp_tool_call_request_meta( &turn_context, @@ -935,6 +940,25 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { /*metadata*/ None, ) .expect("custom servers should receive turn metadata"); + let turn_metadata = meta + .get(crate::X_CODEX_TURN_METADATA_HEADER) + .expect("turn metadata should be present"); + + assert_eq!( + turn_metadata + .get("model") + .and_then(serde_json::Value::as_str), + Some(turn_context.model_info.slug.as_str()) + ); + assert_eq!( + turn_metadata + .get("reasoning_effort") + .and_then(serde_json::Value::as_str), + turn_context + .effective_reasoning_effort() + .map(|effort| effort.to_string()) + .as_deref() + ); assert_eq!( meta, @@ -973,13 +997,10 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() { #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -1023,13 +1044,10 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = serde_json::from_str::( - &turn_context - .turn_metadata_state - .current_header_value() - .expect("turn metadata header"), - ) - .expect("turn metadata json"); + let expected_turn_metadata = turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) + .expect("turn metadata"); assert_eq!( build_mcp_tool_call_request_meta( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b4ef59df95..efd6afdb3a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -119,20 +119,21 @@ impl TurnContext { ) } - pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { + pub(crate) fn effective_reasoning_effort(&self) -> Option { if self.model_info.supports_reasoning_summaries { - match self - .reasoning_effort + self.reasoning_effort .or(self.model_info.default_reasoning_level) - { - Some(effort) => effort.to_string(), - None => "default".to_string(), - } } else { - "default".to_string() + None } } + pub(crate) fn effective_reasoning_effort_for_tracing(&self) -> String { + self.effective_reasoning_effort() + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "default".to_string()) + } + pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; self.model_info diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index f6a338b9ac..11da058b53 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -16,11 +16,19 @@ use codex_git_utils::get_has_changes; use codex_git_utils::get_head_commit_hash; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_utils_absolute_path::AbsolutePathBuf; +const MODEL_KEY: &str = "model"; +const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; +pub(crate) struct McpTurnMetadataContext<'a> { + pub(crate) model: &'a str, + pub(crate) reasoning_effort: Option, +} + #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { associated_remote_urls: Option>, @@ -248,9 +256,28 @@ impl TurnMetadataState { .or(Some(header)) } - pub(crate) fn current_meta_value(&self) -> Option { - self.current_header_value() - .and_then(|header| serde_json::from_str(&header).ok()) + pub(crate) fn current_meta_value_for_mcp_request( + &self, + context: McpTurnMetadataContext<'_>, + ) -> Option { + let header = self.current_header_value()?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + MODEL_KEY.to_string(), + Value::String(context.model.to_string()), + ); + match context.reasoning_effort { + Some(reasoning_effort) => { + metadata.insert( + REASONING_EFFORT_KEY.to_string(), + Value::String(reasoning_effort.to_string()), + ); + } + None => { + metadata.remove(REASONING_EFFORT_KEY); + } + } + Some(Value::Object(metadata)) } pub(crate) fn set_responsesapi_client_metadata( diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 6504eadd67..d16ed27fb4 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::sandbox_tags::sandbox_tag; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -13,6 +14,13 @@ use std::collections::HashMap; use tempfile::TempDir; use tokio::process::Command; +fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> { + McpTurnMetadataContext { + model: "gpt-5.4", + reasoning_effort: Some(ReasoningEffortConfig::High), + } +} + #[tokio::test] async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { let temp_dir = TempDir::new().expect("temp dir"); @@ -158,6 +166,50 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { ); } +#[test] +fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + + let state = TurnMetadataState::new( + "session-a".to_string(), + &SessionSource::Exec, + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!(header_json.get("model").is_none()); + assert!(header_json.get("reasoning_effort").is_none()); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); + assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); + + let meta_without_reasoning_effort = state + .current_meta_value_for_mcp_request(McpTurnMetadataContext { + model: "gpt-5.4", + reasoning_effort: None, + }) + .expect("turn metadata should be present"); + assert_eq!( + meta_without_reasoning_effort["model"].as_str(), + Some("gpt-5.4") + ); + assert!( + meta_without_reasoning_effort + .get("reasoning_effort") + .is_none() + ); +} + #[test] fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let temp_dir = TempDir::new().expect("temp dir"); @@ -202,6 +254,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( state.set_responsesapi_client_metadata(HashMap::from([ ("fiber_run_id".to_string(), "fiber-123".to_string()), ("origin".to_string(), "東京".to_string()), + ("model".to_string(), "client-supplied".to_string()), + ( + "reasoning_effort".to_string(), + "client-supplied".to_string(), + ), ("session_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( @@ -218,6 +275,8 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["fiber_run_id"].as_str(), Some("fiber-123")); assert_eq!(json["origin"].as_str(), Some("東京")); + assert_eq!(json["model"].as_str(), Some("client-supplied")); + assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); @@ -225,4 +284,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( json["turn_started_at_unix_ms"].as_i64(), Some(1_700_000_000_123) ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); + assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); } diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 9573e1b586..eda970d0b2 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -570,6 +570,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - let requests = mock.requests(); assert_eq!(requests.len(), 3); + let first_request_body = requests[0].body_json(); let apps_tool_call = server .received_requests() @@ -604,6 +605,22 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .is_some_and(|turn_id| !turn_id.is_empty()), "apps tools/call should include turn metadata turn_id: {apps_tool_call:?}" ); + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/model") + .and_then(Value::as_str), + Some("gpt-5.4") + ); + let first_request_reasoning_effort = first_request_body + .pointer("/reasoning/effort") + .and_then(Value::as_str) + .expect("first response request should include reasoning effort"); + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/reasoning_effort") + .and_then(Value::as_str), + Some(first_request_reasoning_effort) + ); let mcp_turn_started_at_unix_ms = apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata/turn_started_at_unix_ms") .and_then(Value::as_i64) @@ -626,7 +643,6 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - Some(mcp_turn_started_at_unix_ms) ); - let first_request_body = requests[0].body_json(); let first_request_tools = tool_names(&first_request_body); assert!( first_request_tools From db22c91e61cc80defa5bbafc22bd8a8c7672e5e1 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 5 May 2026 17:49:42 -0700 Subject: [PATCH 07/12] Share Git safe-command logic on Windows (#21275) ## Why BUGB-15601 showed that the Windows safe-command path had drifted from the generic Git classifier. The Windows-specific Git parser could classify a PowerShell-wrapped `git` command as safe as soon as it found a safelisted subcommand, without applying the generic checks for unsafe subcommand options such as `--output`, `--ext-diff`, `--textconv`, `--paginate`, or `cat-file --filters`. The generic classifier already models the Git command boundary and the read-only argument checks more carefully, so Windows should reuse that logic instead of maintaining a smaller parallel parser. ## What Changed - Extracted the existing generic Git classification logic into `is_safe_git_command`. - Updated `windows_safe_commands.rs` to call that shared helper for parsed PowerShell `git` commands. - Removed the Windows-only Git subcommand safelist, including the `cat-file` allowance that was part of the reported bypass. - Added a Windows regression test that keeps PowerShell-wrapped Git commands with side-effecting options classified unsafe. - Made the full-path PowerShell test discover the installed PowerShell executable instead of depending on one hard-coded `pwsh.exe` path. ## Verification - `cargo test -p codex-shell-command rejects_git_subcommand_options_with_side_effects` - `cargo test -p codex-shell-command git_global_override_flags_are_not_safe` - `cargo test -p codex-shell-command windows_powershell_full_path_is_safe -- --nocapture` Co-authored-by: Codex --- .../src/command_safety/is_safe_command.rs | 69 ++++++++++--------- .../command_safety/windows_safe_commands.rs | 69 +++++++++++-------- 2 files changed, 78 insertions(+), 60 deletions(-) diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index 5e2ffec9f5..7200672ec1 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -151,36 +151,7 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } // Git - Some("git") => { - // Global options that redirect config, repository, or helper - // lookup can make otherwise read-only git commands execute - // attacker-controlled code, so they must never be auto-approved. - if git_has_unsafe_global_option(command) { - return false; - } - - let Some((subcommand_idx, subcommand)) = - find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) - else { - return false; - }; - - let subcommand_args = &command[subcommand_idx + 1..]; - - match subcommand { - "status" | "log" | "diff" | "show" => { - git_subcommand_args_are_read_only(subcommand_args) - } - "branch" => { - git_subcommand_args_are_read_only(subcommand_args) - && git_branch_is_read_only(subcommand_args) - } - other => { - debug_assert!(false, "unexpected git subcommand from matcher: {other}"); - false - } - } - } + Some("git") => is_safe_git_command(command), // Special-case `sed -n {N|M,N}p` Some("sed") @@ -198,6 +169,35 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } } +pub(crate) fn is_safe_git_command(command: &[String]) -> bool { + // Global options that redirect config, repository, or helper lookup can make + // otherwise read-only git commands execute attacker-controlled code, so they + // must never be auto-approved. + if git_has_unsafe_global_option(command) { + return false; + } + + let Some((subcommand_idx, subcommand)) = + find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) + else { + return false; + }; + + let subcommand_args = &command[subcommand_idx + 1..]; + + match subcommand { + "status" | "log" | "diff" | "show" => git_subcommand_args_are_read_only(subcommand_args), + "branch" => { + git_subcommand_args_are_read_only(subcommand_args) + && git_branch_is_read_only(subcommand_args) + } + other => { + debug_assert!(false, "unexpected git subcommand from matcher: {other}"); + false + } + } +} + // Treat `git branch` as safe only when the arguments clearly indicate // a read-only query, not a branch mutation (create/rename/delete). fn git_branch_is_read_only(branch_args: &[String]) -> bool { @@ -542,8 +542,15 @@ mod tests { return; } + let Some(powershell) = crate::powershell::try_find_pwsh_executable_blocking() + .or_else(crate::powershell::try_find_powershell_executable_blocking) + else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + assert!(is_known_safe_command(&vec_str(&[ - r"C:\Program Files\PowerShell\7\pwsh.exe", + powershell, "-Command", "Get-Location", ]))); diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index 1dd628f427..ebe0058f75 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -1,4 +1,4 @@ -use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt; +use crate::command_safety::is_safe_command::is_safe_git_command; use crate::command_safety::powershell_parser::PowershellParseOutcome; use crate::command_safety::powershell_parser::parse_with_powershell_ast; use std::path::Path; @@ -221,37 +221,11 @@ fn is_safe_ripgrep(words: &[String]) -> bool { }) } -/// Ensures a Git command sticks to whitelisted read-only subcommands and flags. -fn is_safe_git_command(words: &[String]) -> bool { - const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"]; - - for arg in words.iter().skip(1) { - let arg_lc = arg.to_ascii_lowercase(); - - if arg.starts_with('-') { - if git_global_option_requires_prompt(&arg_lc) - || arg.eq_ignore_ascii_case("--config") - || arg_lc.starts_with("--config=") - { - // Examples rejected here: "pwsh -Command 'git --git-dir=.evil-git diff'" and - // "pwsh -Command 'git -c core.pager=cat show HEAD:foo.rs'". - return false; - } - - continue; - } - - return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str()); - } - - // Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'". - false -} - #[cfg(all(test, windows))] mod tests { use super::*; use crate::powershell::try_find_pwsh_executable_blocking; + use pretty_assertions::assert_eq; use std::string::ToString; /// Converts a slice of string literals into owned `String`s for the tests. @@ -342,7 +316,7 @@ mod tests { assert!(is_safe_command_windows(&[ pwsh.clone(), "-Command".to_string(), - "-git cat-file -p HEAD:foo.rs".to_string() + "git show HEAD:foo.rs".to_string() ])); assert!(is_safe_command_windows(&[ @@ -393,6 +367,43 @@ mod tests { } } + #[test] + fn rejects_git_subcommand_options_with_side_effects() { + let results: Vec<(&str, bool)> = [ + "git diff --output codex_poc.txt", + "git diff --ext-diff HEAD", + "git log --textconv -1", + "git log --paginate -1", + "git show --output=codex_poc.txt HEAD", + "git cat-file --filters HEAD:a.txt", + ] + .into_iter() + .map(|script| { + ( + script, + is_safe_command_windows(&[ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + script.to_string(), + ]), + ) + }) + .collect(); + + assert_eq!( + vec![ + ("git diff --output codex_poc.txt", false), + ("git diff --ext-diff HEAD", false), + ("git log --textconv -1", false), + ("git log --paginate -1", false), + ("git show --output=codex_poc.txt HEAD", false), + ("git cat-file --filters HEAD:a.txt", false), + ], + results + ); + } + #[test] fn rejects_powershell_commands_with_side_effects() { assert!(!is_safe_command_windows(&vec_str(&[ From a736cb55a2bce57b4c8e5a4fe56f70c2b2ad892b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 5 May 2026 18:21:52 -0700 Subject: [PATCH 08/12] release/npm: bundle standalone bwrap on Linux (#21257) --- .github/dotslash-config.json | 12 ++++++++++++ .github/workflows/ci.yml | 2 ++ .github/workflows/rust-release.yml | 14 ++++---------- codex-cli/scripts/build_npm_package.py | 20 ++++++++++++++++++-- codex-cli/scripts/install_native_deps.py | 12 ++++++++++-- scripts/install/install.sh | 10 +++++++++- scripts/stage_npm_packages.py | 19 +++++++++++++++++-- 7 files changed, 72 insertions(+), 17 deletions(-) diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 5caef01e85..801e0eb662 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -84,6 +84,18 @@ } } }, + "bwrap": { + "platforms": { + "linux-x86_64": { + "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", + "path": "bwrap" + }, + "linux-aarch64": { + "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", + "path": "bwrap" + } + } + }, "codex-command-runner": { "platforms": { "windows-x86_64": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5ed5b5bf..699ab070c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,12 @@ jobs: CODEX_VERSION=0.125.0 WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" OUTPUT_DIR="${RUNNER_TEMP}" + # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ + --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 71aab45394..c1d3aff907 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -96,7 +96,7 @@ jobs: target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -108,7 +108,7 @@ jobs: target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl @@ -255,7 +255,7 @@ jobs: with: target: ${{ matrix.target }} - - if: ${{ contains(matrix.target, 'linux') }} + - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} name: Build bwrap and export digest shell: bash run: | @@ -296,7 +296,7 @@ jobs: with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} bwrap + binaries: ${{ matrix.binaries }} - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) @@ -379,12 +379,6 @@ jobs: fi done - if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then - cp "target/${{ matrix.target }}/release/bwrap" "$dest/bwrap-${{ matrix.target }}" - cp "target/${{ matrix.target }}/release/bwrap.sigstore" \ - "$dest/bwrap-${{ matrix.target }}.sigstore" - fi - if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index eda6c26152..c38fed68d9 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -69,8 +69,8 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["codex", "rg"], - "codex-linux-arm64": ["codex", "rg"], + "codex-linux-x64": ["bwrap", "codex", "rg"], + "codex-linux-arm64": ["bwrap", "codex", "rg"], "codex-darwin-x64": ["codex", "rg"], "codex-darwin-arm64": ["codex", "rg"], "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], @@ -87,6 +87,7 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = { PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) COMPONENT_DEST_DIR: dict[str, str] = { + "bwrap": "codex-resources", "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", "codex-windows-sandbox-setup": "codex", @@ -137,6 +138,16 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from --vendor-src. Intended for CI " + "compatibility with older artifact workflows; releases should not use this." + ), + ) return parser.parse_args() @@ -177,6 +188,7 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, + allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -365,12 +377,14 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, + allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") components_set = {component for component in components if component in COMPONENT_DEST_DIR} + allow_missing_components = allow_missing_components or set() if not components_set: return @@ -399,6 +413,8 @@ def copy_native_binaries( src_component_dir = target_dir / dest_dir_name if not src_component_dir.exists(): + if component in allow_missing_components: + continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 58fbd370fc..79f596fbaf 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI plus ripgrep helpers).""" +"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" import argparse from contextlib import contextmanager @@ -42,8 +42,15 @@ class BinaryComponent: WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) +LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) BINARY_COMPONENTS = { + "bwrap": BinaryComponent( + artifact_prefix="bwrap", + dest_dir="codex-resources", + binary_basename="bwrap", + targets=LINUX_TARGETS, + ), "codex": BinaryComponent( artifact_prefix="codex", dest_dir="codex", @@ -135,7 +142,7 @@ def parse_args() -> argparse.Namespace: choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), help=( "Limit installation to the specified components." - " May be repeated. Defaults to codex, codex-windows-sandbox-setup," + " May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup," " codex-command-runner, and rg." ), ) @@ -159,6 +166,7 @@ def main() -> int: vendor_dir.mkdir(parents=True, exist_ok=True) components = args.components or [ + "bwrap", "codex", "codex-windows-sandbox-setup", "codex-command-runner", diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 8c225e4d3b..2fc585d7e9 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -596,6 +596,10 @@ install_release() { cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg" chmod 0755 "$stage_release/codex" chmod 0755 "$stage_release/codex-resources/rg" + if [ -f "$vendor_root/codex-resources/bwrap" ]; then + cp "$vendor_root/codex-resources/bwrap" "$stage_release/codex-resources/bwrap" + chmod 0755 "$stage_release/codex-resources/bwrap" + fi if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then rm -rf "$release_dir" @@ -611,7 +615,11 @@ release_dir_is_complete() { [ -d "$release_dir" ] && [ -x "$release_dir/codex" ] && [ -x "$release_dir/codex-resources/rg" ] && - [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] + [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] && + case "$expected_target" in + *linux*) [ -x "$release_dir/codex-resources/bwrap" ] ;; + *) true ;; + esac } update_current_link() { diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e9..2a0cd0028d 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -58,6 +58,16 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Retain temporary staging directories instead of deleting them.", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from reused workflow artifacts. " + "Intended for CI compatibility only; release staging should not use this." + ), + ) return parser.parse_args() @@ -147,6 +157,8 @@ def main() -> int: packages = expand_packages(list(args.packages)) native_components = collect_native_components(packages) + allow_missing_native_components = set(args.allow_missing_native_components) + native_components_to_install = native_components - allow_missing_native_components vendor_temp_root: Path | None = None vendor_src: Path | None = None @@ -155,12 +167,12 @@ def main() -> int: final_messages = [] try: - if native_components: + if native_components_to_install: workflow_url, resolved_head_sha = resolve_workflow_url( args.release_version, args.workflow_url ) vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components(workflow_url, native_components, vendor_temp_root) + install_native_components(workflow_url, native_components_to_install, vendor_temp_root) vendor_src = vendor_temp_root / "vendor" if resolved_head_sha: @@ -185,6 +197,9 @@ def main() -> int: if vendor_src is not None: cmd.extend(["--vendor-src", str(vendor_src)]) + for component in sorted(allow_missing_native_components): + cmd.extend(["--allow-missing-native-component", component]) + try: run_command(cmd) finally: From 024118625ef5a26f6e787b7989bbd20746cf2fcd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 5 May 2026 18:57:17 -0700 Subject: [PATCH 09/12] [codex] Remove unused ListModels op (#21276) ## Why The core protocol still exposed a `ListModels` submission op even though no client sends it and the core submission loop treated it as an ignored unknown op. Keeping the dead variant made the protocol surface look supported while the active model listing API is the app-server `model/list` JSON-RPC request. ## What Changed - Removed the unused `Op::ListModels` variant from `codex-rs/protocol`. - Removed its `Op::kind()` mapping. The existing app-server `model/list` endpoint is unchanged. ## Verification - `cargo test -p codex-protocol` --- codex-rs/protocol/src/protocol.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ab294ab157..bce929d214 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -796,9 +796,6 @@ pub enum Op { /// The raw command string after '!' command: String, }, - - /// Request the list of available models. - ListModels, } #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)] @@ -904,7 +901,6 @@ impl Op { Self::ApproveGuardianDeniedAction { .. } => "approve_guardian_denied_action", Self::Shutdown => "shutdown", Self::RunUserShellCommand { .. } => "run_user_shell_command", - Self::ListModels => "list_models", } } } From 136e442e95180f1179a5615b7268946a0d6fa06e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 5 May 2026 18:58:18 -0700 Subject: [PATCH 10/12] [codex] Remove legacy ListSkills op (#21282) ## Why `skills/list` is already exposed through app-server v2 and covered by the app-server test suite. Keeping the separate core `Op::ListSkills` path leaves a duplicate legacy protocol surface that no longer needs to be maintained. ## What Changed - Removed `Op::ListSkills` and `EventMsg::ListSkillsResponse` from the core protocol. - Deleted the corresponding core session handler and stale core integration tests. - Removed rollout/MCP ignore branches and protocol v1 docs references for the deleted event/op. - Left app-server `skills/list` and its existing coverage intact. ## Validation - `cargo test -p codex-protocol` - `cargo test -p codex-core --test all suite::skills` - `cargo check -p codex-mcp-server -p codex-rollout -p codex-rollout-trace` - `just fix -p codex-core` --- codex-rs/core/src/lib.rs | 2 - codex-rs/core/src/session/handlers.rs | 108 +------ codex-rs/core/src/session/mod.rs | 63 +--- codex-rs/core/src/session/turn.rs | 1 - codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/skills.rs | 293 ------------------- codex-rs/docs/protocol_v1.md | 2 - codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/protocol.rs | 36 --- codex-rs/rollout-trace/src/protocol_event.rs | 2 - codex-rs/rollout/src/policy.rs | 1 - 11 files changed, 5 insertions(+), 505 deletions(-) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cbada9a26c..4cdfc5ea23 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -89,11 +89,9 @@ mod session_prefix; mod session_startup_prewarm; mod shell_detect; pub mod skills; -pub(crate) use skills::SkillError; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; pub(crate) use skills::SkillMetadata; -pub(crate) use skills::SkillsLoadInput; pub(crate) use skills::SkillsManager; pub(crate) use skills::build_available_skills; pub(crate) use skills::build_skill_injections; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 6d507e6ffd..d2ba747aff 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -18,14 +18,8 @@ use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET; use crate::realtime_context::truncate_realtime_text_to_token_budget; use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX; use crate::realtime_conversation::prefix_realtime_v2_text; -use crate::session::spawn_review_thread; -use codex_config::CloudRequirementsLoader; -use codex_config::LoaderOverrides; -use codex_config::loader::load_config_layers_state; -use codex_exec_server::LOCAL_FS; -use codex_utils_absolute_path::AbsolutePathBuf; - use crate::review_prompts::resolve_review_request; +use crate::session::spawn_review_thread; use crate::tasks::CompactTask; use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; @@ -41,7 +35,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::InterAgentCommunication; -use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationListVoicesResponseEvent; @@ -49,8 +42,6 @@ use codex_protocol::protocol::RealtimeVoicesList; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SkillErrorInfo; -use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; @@ -69,7 +60,6 @@ use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use serde_json::Value; -use std::path::PathBuf; use std::sync::Arc; use tracing::debug; use tracing::info; @@ -555,98 +545,6 @@ pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String sess.send_event_raw(event).await; } -pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec, force_reload: bool) { - let default_cwd = { - let state = sess.state.lock().await; - state.session_configuration.cwd.to_path_buf() - }; - let cwds = if cwds.is_empty() { - vec![default_cwd] - } else { - cwds - }; - - let skills_manager = &sess.services.skills_manager; - let plugins_manager = &sess.services.plugins_manager; - let fs = sess - .services - .environment_manager - .default_environment() - .map(|environment| environment.get_filesystem()); - let config = sess.get_config().await; - let codex_home = sess.codex_home().await; - let mut skills = Vec::new(); - let empty_cli_overrides: &[(String, toml::Value)] = &[]; - for cwd in cwds { - let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { - Ok(path) => path, - Err(err) => { - let error_path = cwd.clone(); - skills.push(SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let config_layer_stack = match load_config_layers_state( - LOCAL_FS.as_ref(), - &codex_home, - Some(cwd_abs.clone()), - empty_cli_overrides, - LoaderOverrides::default(), - CloudRequirementsLoader::default(), - &codex_config::NoopThreadConfigLoader, - ) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { - let error_path = cwd.clone(); - skills.push(SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let plugins_input = config.plugins_config_input(); - let effective_skill_roots = plugins_manager - .effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input) - .await; - let skills_input = crate::SkillsLoadInput::new( - cwd_abs.clone(), - effective_skill_roots, - config_layer_stack, - config.bundled_skills_enabled(), - ); - let outcome = skills_manager - .skills_for_cwd(&skills_input, force_reload, fs.clone()) - .await; - let errors = super::errors_to_info(&outcome.errors); - let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths); - skills.push(SkillsListEntry { - cwd, - skills: skills_metadata, - errors, - }); - } - - let event = Event { - id: sub_id, - msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), - }; - sess.send_event_raw(event).await; -} - pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; @@ -1032,10 +930,6 @@ pub(super) async fn submission_loop( reload_user_config(&sess).await; false } - Op::ListSkills { cwds, force_reload } => { - list_skills(&sess, sub.id.clone(), cwds, force_reload).await; - false - } Op::Compact => { compact(&sess, sub.id.clone()).await; false diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 85f2ef438b..4bb57d3f7d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -266,8 +266,9 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } -use crate::SkillError; +#[cfg(test)] use crate::SkillLoadOutcome; +#[cfg(test)] use crate::SkillMetadata; use crate::SkillsManager; use crate::agents_md::AgentsMdManager; @@ -342,11 +343,6 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionNetworkProxyRuntime; -use codex_protocol::protocol::SkillDependencies as ProtocolSkillDependencies; -use codex_protocol::protocol::SkillErrorInfo; -use codex_protocol::protocol::SkillInterface as ProtocolSkillInterface; -use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency; use codex_protocol::protocol::StreamErrorEvent; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; @@ -984,6 +980,7 @@ impl Session { } } + #[cfg(test)] pub(crate) async fn codex_home(&self) -> AbsolutePathBuf { let state = self.state.lock().await; state.session_configuration.codex_home().clone() @@ -3293,60 +3290,6 @@ pub(crate) fn emit_subagent_session_started( }); } -fn skills_to_info( - skills: &[SkillMetadata], - disabled_paths: &HashSet, -) -> Vec { - skills - .iter() - .map(|skill| ProtocolSkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill - .interface - .clone() - .map(|interface| ProtocolSkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - }), - dependencies: skill.dependencies.clone().map(|dependencies| { - ProtocolSkillDependencies { - tools: dependencies - .tools - .into_iter() - .map(|tool| ProtocolSkillToolDependency { - r#type: tool.r#type, - value: tool.value, - description: tool.description, - transport: tool.transport, - command: tool.command, - url: tool.url, - }) - .collect(), - } - }), - path: skill.path_to_skills_md.clone(), - scope: skill.scope, - enabled: !disabled_paths.contains(&skill.path_to_skills_md), - }) - .collect() -} - -fn errors_to_info(errors: &[SkillError]) -> Vec { - errors - .iter() - .map(|err| SkillErrorInfo { - path: err.path.to_path_buf(), - message: err.message.clone(), - }) - .collect() -} - use codex_memories_read::build_memory_tool_developer_instructions; /// Builds the hook engine for one config snapshot, including any enabled plugin hooks. diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 75f4762c9e..d74ddd5fe9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1506,7 +1506,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::TurnDiff(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index efd6afdb3a..00ff985861 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -1,4 +1,5 @@ use super::*; +use crate::SkillLoadOutcome; use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index ebf2c3226e..894110a6fb 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -2,22 +2,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Result; -use codex_core::ThreadManager; -use codex_core::agent_graph_store_from_state_db; -use codex_core::init_state_db_from_config; -use codex_core::thread_store_from_config; use codex_exec_server::CreateDirectoryOptions; -use codex_exec_server::EnvironmentManager; -use codex_exec_server::ExecServerRuntimePaths; use codex_exec_server::ExecutorFileSystem; -use codex_login::CodexAuth; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Op; -use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -27,11 +18,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; -use pretty_assertions::assert_eq; -use std::fs; -use std::path::Path; use std::sync::Arc; -use tempfile::TempDir; async fn write_repo_skill( cwd: AbsolutePathBuf, @@ -54,22 +41,6 @@ async fn write_repo_skill( Ok(()) } -fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) -> Result<()> { - let skill_dir = codex_home.join("skills").join(dir); - fs::create_dir_all(&skill_dir)?; - let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); - fs::write(skill_dir.join("SKILL.md"), contents)?; - Ok(()) -} - -fn system_skill_md_path(home: impl AsRef, name: &str) -> std::path::PathBuf { - home.as_ref() - .join("skills") - .join(".system") - .join(name) - .join("SKILL.md") -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_includes_skill_instructions() -> Result<()> { skip_if_no_network!(Ok(())); @@ -150,267 +121,3 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_includes_repo_and_home_skills_remote_aware() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex() - .with_pre_build_hook(|home| { - write_home_skill(home, "home-demo", "home-demo", "from home") - .expect("write home skill"); - }) - .with_workspace_setup(|cwd, fs| async move { - write_repo_skill(cwd, fs, "repo-demo", "from repo", "# Body").await - }); - let test = builder.build_remote_aware(&server).await?; - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.config.cwd.as_path(); - let skills = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| entry.skills.clone()) - .unwrap_or_default(); - - let repo_skill = skills - .iter() - .find(|skill| skill.name == "repo-demo") - .expect("expected repo skill"); - assert_eq!(repo_skill.scope, codex_protocol::protocol::SkillScope::Repo); - let repo_path = repo_skill.path.to_string_lossy().replace('\\', "/"); - assert!( - repo_path.ends_with("/.agents/skills/repo-demo/SKILL.md"), - "unexpected repo skill path: {repo_path}" - ); - - let home_skill = skills - .iter() - .find(|skill| skill.name == "home-demo") - .expect("expected home skill"); - assert_eq!(home_skill.scope, codex_protocol::protocol::SkillScope::User); - let home_path = home_skill.path.to_string_lossy().replace('\\', "/"); - assert!( - home_path.ends_with("/skills/home-demo/SKILL.md"), - "unexpected home skill path: {home_path}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - write_home_skill( - codex_home.path(), - "home-disabled", - "home-disabled", - "from home", - )?; - let repo_skill_dir = cwd - .path() - .join(".agents") - .join("skills") - .join("repo-disabled"); - fs::create_dir_all(&repo_skill_dir)?; - fs::write( - repo_skill_dir.join("SKILL.md"), - "---\nname: repo-disabled\ndescription: from repo\n---\n\n# Body\n", - )?; - let mut config = load_default_config_for_test(&codex_home).await; - config.cwd = AbsolutePathBuf::from_absolute_path_checked(cwd.path())?; - - let state_db = init_state_db_from_config(&config) - .await - .expect("skills test requires state db"); - let thread_store = thread_store_from_config(&config, state_db.clone()); - let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); - let thread_manager = ThreadManager::new( - &config, - codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")), - SessionSource::Exec, - Arc::new(EnvironmentManager::disabled_for_tests( - ExecServerRuntimePaths::new( - std::env::current_exe()?, - /*codex_linux_sandbox_exe*/ None, - )?, - )), - /*analytics_events_client*/ None, - state_db, - thread_store, - agent_graph_store, - ); - let new_thread = thread_manager.start_thread(config.clone()).await?; - let cwd = config.cwd.to_path_buf(); - - new_thread - .thread - .submit(Op::ListSkills { - cwds: vec![cwd.clone()], - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(new_thread.thread.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - assert_eq!(response.skills.len(), 1); - assert_eq!(response.skills[0].cwd, cwd); - assert_eq!(response.skills[0].errors.len(), 0); - assert!( - response.skills[0] - .skills - .iter() - .any(|skill| skill.name == "home-disabled") - ); - assert!( - response.skills[0] - .skills - .iter() - .all(|skill| skill.name != "repo-disabled") - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skill_load_errors_surface_in_session_configured() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_pre_build_hook(|home| { - let skill_dir = home.join("skills").join("broken"); - fs::create_dir_all(&skill_dir).unwrap(); - fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap(); - }); - let test = builder.build(&server).await?; - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: false, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.cwd_path(); - let (skills, errors) = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| (entry.skills.clone(), entry.errors.clone())) - .unwrap_or_default(); - - assert!( - skills.iter().all(|skill| { - !skill - .path - .to_string_lossy() - .ends_with("skills/broken/SKILL.md") - }), - "expected broken skill not loaded, got {skills:?}" - ); - assert_eq!(errors.len(), 1, "expected one load error"); - let error_path = errors[0].path.to_string_lossy(); - assert!( - error_path.ends_with("skills/broken/SKILL.md"), - "unexpected error path: {error_path}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_skills_includes_system_cache_entries() -> Result<()> { - skip_if_no_network!(Ok(())); - - const SYSTEM_SKILL_NAME: &str = "skill-creator"; - - let server = start_mock_server().await; - let mut builder = test_codex().with_pre_build_hook(|home| { - let system_skill_path = system_skill_md_path(home, SYSTEM_SKILL_NAME); - assert!( - !system_skill_path.exists(), - "expected embedded system skills not yet installed, but {system_skill_path:?} exists" - ); - }); - let test = builder.build(&server).await?; - - let system_skill_path = system_skill_md_path(test.codex_home_path(), SYSTEM_SKILL_NAME); - assert!( - system_skill_path.exists(), - "expected embedded system skills installed to {system_skill_path:?}" - ); - let system_skill_contents = fs::read_to_string(&system_skill_path)?; - let expected_name_line = format!("name: {SYSTEM_SKILL_NAME}"); - assert!( - system_skill_contents.contains(&expected_name_line), - "expected embedded system skill file, got:\n{system_skill_contents}" - ); - - test.codex - .submit(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }) - .await?; - let response = - core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { - codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => { - Some(response.clone()) - } - _ => None, - }) - .await; - - let cwd = test.cwd_path(); - let (skills, _errors) = response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| (entry.skills.clone(), entry.errors.clone())) - .unwrap_or_default(); - - let skill = skills - .iter() - .find(|skill| skill.name == SYSTEM_SKILL_NAME) - .expect("expected system skill to be present"); - assert_eq!(skill.scope, codex_protocol::protocol::SkillScope::System); - let path_str = skill.path.to_string_lossy().replace('\\', "/"); - let expected_path_suffix = format!("/skills/.system/{SYSTEM_SKILL_NAME}/SKILL.md"); - assert!( - path_str.ends_with(&expected_path_suffix), - "unexpected skill path: {path_str}" - ); - - Ok(()) -} diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 9f238b40ee..d18aa669ca 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -70,7 +70,6 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - - `Op::ListSkills` – Request skills for one or more cwd values (optionally `force_reload`) - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. @@ -86,7 +85,6 @@ Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` - `EventMsg::Error` – A turn stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the turn. This can be used to continue the turn at a later point in time, perhaps with additional user input. - - `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) ### UserInput items diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4ec8c51434..772cae63b9 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -335,7 +335,6 @@ async fn run_codex_tool_session_inner( | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bce929d214..e8f4a80d6a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -748,19 +748,6 @@ pub enum Op { /// enable/disable state) without restarting the thread. ReloadUserConfig, - /// Request the list of skills for the provided `cwd` values or the session default. - ListSkills { - /// Working directories to scope repo skills discovery. - /// - /// When empty, the session default working directory is used. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - cwds: Vec, - - /// When true, recompute skills even if a cached result exists. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - force_reload: bool, - }, - /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -893,7 +880,6 @@ impl Op { Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", - Self::ListSkills { .. } => "list_skills", Self::Compact => "compact", Self::SetThreadMemoryMode { .. } => "set_thread_memory_mode", Self::ThreadRollback { .. } => "thread_rollback", @@ -1441,9 +1427,6 @@ pub enum EventMsg { /// List of MCP tools available to the agent. McpListToolsResponse(McpListToolsResponseEvent), - /// List of skills available to the agent. - ListSkillsResponse(ListSkillsResponseEvent), - /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), @@ -3312,12 +3295,6 @@ impl fmt::Display for McpAuthStatus { } } -/// Response payload for `Op::ListSkills`. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ListSkillsResponseEvent { - pub skills: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RealtimeConversationListVoicesResponseEvent { pub voices: RealtimeVoicesList, @@ -3427,19 +3404,6 @@ pub struct SkillToolDependency { pub url: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq)] pub struct SessionNetworkProxyRuntime { pub http_addr: String, diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 9132ba91e7..4471474154 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -261,7 +261,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::TurnDiff(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 6aefff7480..a965231ef1 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -159,7 +159,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) - | EventMsg::ListSkillsResponse(_) | EventMsg::WebSearchBegin(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete From 94db03d5afc8bb0df519a76fc098aaac0cda5fa6 Mon Sep 17 00:00:00 2001 From: Abdulrahman Alfozan Date: Tue, 5 May 2026 22:09:05 -0400 Subject: [PATCH 11/12] Expose plugin manifest keywords in app server (#21271) ## Summary - Add plugin manifest keywords to core plugin marketplace/detail models - Expose keywords on app-server v2 PluginSummary and generated schema/types - Populate keywords in plugin/list and plugin/read responses for local plugins Depends on https://github.com/openai/openai/pull/891087 ## Validation - just fmt - just write-app-server-schema - cargo test -p codex-app-server-protocol - cargo test -p codex-core-plugins - cargo test -p codex-app-server plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load - cargo test -p codex-app-server plugin_read_returns_plugin_details_with_bundle_contents --- .../codex_app_server_protocol.schemas.json | 7 +++++ .../codex_app_server_protocol.v2.schemas.json | 7 +++++ .../schema/json/v2/PluginListResponse.json | 7 +++++ .../schema/json/v2/PluginReadResponse.json | 7 +++++ .../json/v2/PluginShareListResponse.json | 7 +++++ .../schema/typescript/v2/PluginSummary.ts | 2 +- .../src/protocol/v2/plugin.rs | 2 ++ .../src/protocol/v2/tests.rs | 2 ++ .../src/request_processors/plugins.rs | 3 +++ .../app-server/tests/suite/v2/plugin_list.rs | 13 ++++++++- .../app-server/tests/suite/v2/plugin_read.rs | 13 +++++++++ .../app-server/tests/suite/v2/plugin_share.rs | 3 +++ codex-rs/core-plugins/src/manager.rs | 10 +++++++ codex-rs/core-plugins/src/manager_tests.rs | 7 +++++ codex-rs/core-plugins/src/manifest.rs | 27 +++++++++++++++++++ codex-rs/core-plugins/src/marketplace.rs | 5 ++++ .../core-plugins/src/marketplace_tests.rs | 13 +++++++++ codex-rs/core-plugins/src/remote.rs | 4 +++ .../core-plugins/src/remote/share/tests.rs | 2 ++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 20 files changed, 140 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 8e0e2c39b4..a1519e4240 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12710,6 +12710,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 67aaf9e389..5cecb71ed5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9321,6 +9321,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index dc383608f2..bf1ce45755 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -347,6 +347,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 2762807c7d..c1736cdcc1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -366,6 +366,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index adb5021be8..bae3eb034f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -307,6 +307,13 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "name": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index fe9e63703d..8a760e2e5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -11,4 +11,4 @@ export type PluginSummary = { id: string, name: string, source: PluginSource, in /** * Availability state for installing and using the plugin. */ -availability: PluginAvailability, interface: PluginInterface | null, }; +availability: PluginAvailability, interface: PluginInterface | null, keywords: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 09f9325317..a6c22bfc9f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -446,6 +446,8 @@ pub struct PluginSummary { #[serde(default)] pub availability: PluginAvailability, pub interface: Option, + #[serde(default)] + pub keywords: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 5314c73b00..719a933f22 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3044,6 +3044,7 @@ fn plugin_share_list_response_serializes_share_items() { auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: None, + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, @@ -3062,6 +3063,7 @@ fn plugin_share_list_response_serializes_share_items() { "authPolicy": "ON_USE", "availability": "AVAILABLE", "interface": null, + "keywords": [], }, "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", "localPluginPath": null, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 40f2a412ac..d9f66d65ce 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -310,6 +310,7 @@ impl PluginRequestProcessor { auth_policy: plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: plugin.interface.map(local_plugin_interface_to_info), + keywords: plugin.keywords, }) .collect(), }) @@ -465,6 +466,7 @@ impl PluginRequestProcessor { auth_policy: outcome.plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + keywords: outcome.plugin.keywords, }, description: outcome.plugin.description, skills: plugin_skills_to_info( @@ -1270,6 +1272,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS auth_policy: summary.auth_policy, availability: summary.availability, interface: summary.interface, + keywords: summary.keywords, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 86fb78bae1..e82ebe9fe9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -197,7 +197,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ valid_repo_root .path() .join("plugins/valid-plugin/.codex-plugin/plugin.json"), - r#"{"name":"valid-plugin"}"#, + r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#, )?; std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; @@ -246,6 +246,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: vec!["api-key".to_string(), "developer tools".to_string()], }], }] ); @@ -548,6 +549,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab screenshots: Vec::new(), screenshot_urls: Vec::new(), }), + keywords: Vec::new(), }, PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), @@ -563,6 +565,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: Vec::new(), }, ], }] @@ -1295,6 +1298,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -1430,6 +1434,13 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and_then(|interface| interface.display_name.as_deref()), Some("Linear") ); + assert_eq!( + remote_marketplace.plugins[0].keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index fd082ab412..bb42714d3d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -172,6 +172,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -281,6 +282,13 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> response.plugin.description.as_deref(), Some("Track work in Linear") ); + assert_eq!( + response.plugin.summary.keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!(response.plugin.skills[0].name, "plan-work"); assert_eq!(response.plugin.skills[0].path, None); @@ -580,6 +588,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> r##"{ "name": "demo-plugin", "description": "Longer manifest description", + "keywords": ["api-key", "developer tools"], "interface": { "displayName": "Plugin Display Name", "shortDescription": "Short description for subtitle", @@ -740,6 +749,10 @@ enabled = true "Find my next action".to_string() ]) ); + assert_eq!( + response.plugin.summary.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!( response.plugin.skills[0].name, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index a44a64be7c..604935b6e5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -164,6 +164,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: Some(expected_plugin_path), @@ -239,6 +240,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, @@ -342,6 +344,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index ba56646649..2c974ef966 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -223,6 +223,7 @@ pub struct PluginDetail { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, pub skills: Vec, @@ -252,6 +253,7 @@ pub struct ConfiguredMarketplacePlugin { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, } @@ -1196,6 +1198,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin.keywords, }) }) .collect::>(); @@ -1245,6 +1248,11 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin + .manifest + .as_ref() + .map(|manifest| manifest.keywords.clone()) + .unwrap_or_default(), installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), }, @@ -1287,6 +1295,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: Vec::new(), @@ -1363,6 +1372,7 @@ impl PluginsManager { source: plugin.source, policy: plugin.policy, interface, + keywords: manifest.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: resolved_skills.skills, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 8abff7700b..50d0d46258 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1550,6 +1550,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: true, }, @@ -1566,6 +1567,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: false, }, @@ -1684,6 +1686,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -2068,6 +2071,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }], @@ -2361,6 +2365,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }] @@ -2390,6 +2395,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -2473,6 +2479,7 @@ enabled = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }], diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 12b738f537..6de7f820b8 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -18,6 +18,8 @@ struct RawPluginManifest { version: Option, #[serde(default)] description: Option, + #[serde(default)] + keywords: Vec, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] @@ -37,6 +39,7 @@ pub struct PluginManifest { pub name: String, pub version: Option, pub description: Option, + pub keywords: Vec, pub paths: PluginManifestPaths, pub interface: Option, } @@ -143,6 +146,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name: raw_name, version, description, + keywords, skills, mcp_servers, apps, @@ -232,6 +236,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name, version, description, + keywords, paths: PluginManifestPaths { skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), mcp_servers: resolve_manifest_path( @@ -568,6 +573,28 @@ mod tests { assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); } + #[test] + fn plugin_manifest_reads_keywords() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "keywords": ["api-key", "developer tools"] +}"#, + ) + .expect("write manifest"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); + } + #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index dc6d01adf9..f66b5d1b22 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -62,6 +62,7 @@ pub struct MarketplacePlugin { pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -293,6 +294,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -321,6 +322,8 @@ struct RemotePluginReleaseResponse { bundle_download_url: Option, #[serde(default)] app_ids: Vec, + #[serde(default)] + keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, @@ -771,6 +774,7 @@ fn build_remote_plugin_summary( auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), } } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index efdecdbbbc..65a989c26e 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -442,6 +442,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), local_plugin_path: Some(local_plugin_path), @@ -456,6 +457,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, share_url: None, local_plugin_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 3f7c9bd5b2..1db968f7e0 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1448,6 +1448,7 @@ pub(super) fn plugins_test_summary( description, /*long_description*/ None, )), + keywords: Vec::new(), } } From b3d4f1a9f06d1898b666b0bf76a56ca18971f189 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 5 May 2026 19:12:31 -0700 Subject: [PATCH 12/12] [codex-analytics] rework thread_source for thread analytics (#20949) ## Summary - make `thread_source` an explicit optional thread-level field on `thread/start`, `thread/fork`, and returned thread payloads - persist `thread_source` in rollout/session metadata so resumed live threads retain the original value - replace the old best-effort `session_source` -> `thread_source` mapping with an explicit caller-supplied analytics classification ## Why Before this change, analytics `thread_source` was populated by a best-effort mapping from `session_source`. `session_source` describes the runtime/client surface, not the actual thread-level origin, so that projection was not accurate enough to distinguish cases such as `user`, `subagent`, `memory_consolidation`, and future thread origins reliably. Making `thread_source` explicit keeps one thread-level analytics field while letting callers provide the real classification directly instead of recovering it indirectly from `session_source`. ## Impact For new analytics events, `thread_source` now reflects the explicit thread-level classification supplied by the caller rather than an inferred value derived from `session_source`. Existing protocol fields remain optional; callers that omit `threadSource` now produce `null` instead of a best-effort inferred value. ## Validation - `just write-app-server-schema` - `cargo test -p codex-analytics -p codex-core -p codex-app-server-protocol --no-run` - `cargo test -p codex-app-server-protocol generated_ts_optional_nullable_fields_only_in_params` - `cargo test -p codex-analytics thread_initialized_event_serializes_expected_shape` - `cargo test -p codex-core resume_stopped_thread_from_rollout_preserves_thread_source` --- .../analytics/src/analytics_client_tests.rs | 28 ++++-- codex-rs/analytics/src/client_tests.rs | 1 + codex-rs/analytics/src/events.rs | 13 +-- codex-rs/analytics/src/reducer.rs | 21 +++-- .../schema/json/ClientRequest.json | 30 ++++++ .../schema/json/ServerNotification.json | 19 ++++ .../codex_app_server_protocol.schemas.json | 41 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 41 ++++++++ .../schema/json/v2/ThreadForkParams.json | 19 ++++ .../schema/json/v2/ThreadForkResponse.json | 19 ++++ .../schema/json/v2/ThreadListResponse.json | 19 ++++ .../json/v2/ThreadMetadataUpdateResponse.json | 19 ++++ .../schema/json/v2/ThreadReadResponse.json | 19 ++++ .../schema/json/v2/ThreadResumeResponse.json | 19 ++++ .../json/v2/ThreadRollbackResponse.json | 19 ++++ .../schema/json/v2/ThreadStartParams.json | 19 ++++ .../schema/json/v2/ThreadStartResponse.json | 19 ++++ .../json/v2/ThreadStartedNotification.json | 19 ++++ .../json/v2/ThreadUnarchiveResponse.json | 19 ++++ .../schema/typescript/v2/Thread.ts | 5 + .../schema/typescript/v2/ThreadForkParams.ts | 6 +- .../schema/typescript/v2/ThreadSource.ts | 5 + .../schema/typescript/v2/ThreadStartParams.ts | 6 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 2 + .../src/protocol/v2/thread.rs | 7 ++ .../src/protocol/v2/thread_data.rs | 32 +++++++ .../app-server/src/bespoke_event_handling.rs | 1 + .../external_agent_config_processor.rs | 1 + .../request_processors/thread_processor.rs | 20 ++++ .../thread_processor_tests.rs | 5 + .../src/request_processors/thread_summary.rs | 1 + .../src/request_processors/turn_processor.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 2 + .../app-server/tests/suite/v2/analytics.rs | 6 +- .../app-server/tests/suite/v2/skills_list.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 14 ++- .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 34 ++++++- .../app-server/tests/suite/v2/thread_start.rs | 22 ++++- .../app-server/tests/suite/v2/turn_start.rs | 4 + codex-rs/core/src/agent/control.rs | 3 + codex-rs/core/src/codex_delegate.rs | 2 + codex-rs/core/src/codex_thread.rs | 2 + .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/realtime_context_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 4 + codex-rs/core/src/session/review.rs | 3 +- codex-rs/core/src/session/session.rs | 6 ++ codex-rs/core/src/session/tests.rs | 10 ++ .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 6 +- codex-rs/core/src/thread_manager.rs | 29 ++++++ codex-rs/core/src/thread_manager_tests.rs | 90 ++++++++++++++++++ codex-rs/core/src/turn_metadata.rs | 10 +- codex-rs/core/src/turn_metadata_tests.rs | 19 ++-- codex-rs/core/tests/responses_headers.rs | 6 +- .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/fork_thread.rs | 3 + .../core/tests/suite/permissions_messages.rs | 1 + .../core/tests/suite/personality_migration.rs | 2 + .../core/tests/suite/rollout_list_find.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/exec/src/lib_tests.rs | 2 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/memories/write/src/runtime.rs | 2 + codex-rs/protocol/src/protocol.rs | 93 ++++++++++++------- codex-rs/rollout/src/metadata_tests.rs | 3 + codex-rs/rollout/src/recorder.rs | 6 ++ codex-rs/rollout/src/recorder_tests.rs | 4 + codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + .../migrations/0030_threads_thread_source.sql | 1 + codex-rs/state/src/extract.rs | 4 + codex-rs/state/src/model/thread_metadata.rs | 17 ++++ codex-rs/state/src/runtime/memories.rs | 1 + codex-rs/state/src/runtime/test_support.rs | 1 + codex-rs/state/src/runtime/threads.rs | 21 ++++- codex-rs/thread-store/src/in_memory.rs | 1 + .../thread-store/src/local/create_thread.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + .../thread-store/src/local/read_thread.rs | 7 +- codex-rs/thread-store/src/remote/helpers.rs | 7 ++ .../thread-store/src/remote/list_threads.rs | 2 + codex-rs/thread-store/src/remote/mod.rs | 1 + .../remote/proto/codex.thread_store.v1.proto | 1 + .../src/remote/proto/codex.thread_store.v1.rs | 2 + codex-rs/thread-store/src/types.rs | 5 + codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/resume_picker.rs | 4 + 98 files changed, 896 insertions(+), 90 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts create mode 100644 codex-rs/state/migrations/0030_threads_thread_source.sql diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d2aadc8707..823dcace91 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -79,6 +79,7 @@ use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource as AppServerThreadSource; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; use codex_app_server_protocol::Turn; @@ -107,6 +108,7 @@ use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -118,14 +120,11 @@ use std::sync::Arc; use std::sync::Mutex; use tokio::sync::mpsc; -fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread { - sample_thread_with_source(thread_id, ephemeral, AppServerSessionSource::Exec) -} - -fn sample_thread_with_source( +fn sample_thread_with_metadata( thread_id: &str, ephemeral: bool, source: AppServerSessionSource, + thread_source: Option, ) -> Thread { Thread { id: thread_id.to_string(), @@ -140,6 +139,7 @@ fn sample_thread_with_source( cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + thread_source, agent_nickname: None, agent_role: None, git_info: None, @@ -154,7 +154,12 @@ fn sample_thread_start_response( model: &str, ) -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { - thread: sample_thread(thread_id, ephemeral), + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), + ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -198,6 +203,7 @@ fn sample_thread_resume_response( ephemeral, model, AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), ) } @@ -206,9 +212,10 @@ fn sample_thread_resume_response_with_source( ephemeral: bool, model: &str, source: AppServerSessionSource, + thread_source: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - thread: sample_thread_with_source(thread_id, ephemeral, source), + thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -753,7 +760,7 @@ fn compaction_event_serializes_expected_shape() { }, sample_app_server_client_metadata(), sample_runtime_metadata(), - Some("user"), + Some(ThreadSource::User), /*subagent_source*/ None, /*parent_thread_id*/ None, ), @@ -852,7 +859,7 @@ fn thread_initialized_event_serializes_expected_shape() { }, model: "gpt-5".to_string(), ephemeral: true, - thread_source: Some("user"), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, @@ -1196,6 +1203,7 @@ async fn compaction_event_ingests_custom_fact() { agent_nickname: None, agent_role: None, }), + Some(AppServerThreadSource::Subagent), )), }, &mut events, @@ -2116,7 +2124,7 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, - thread_source: Some("user".to_string()), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 14ce570d79..c36b5cf9a7 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -87,6 +87,7 @@ fn sample_thread(thread_id: &str) -> Thread { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: AppServerSessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ca8febd1b3..383cf6857e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -33,6 +33,7 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Serialize; @@ -126,7 +127,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -647,7 +648,7 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) trigger: CompactionTrigger, @@ -680,7 +681,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -733,7 +734,7 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, @@ -836,7 +837,7 @@ pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, - thread_source: Option<&'static str>, + thread_source: Option, subagent_source: Option, parent_thread_id: Option, ) -> CodexCompactionEventParams { @@ -940,7 +941,7 @@ pub(crate) fn subagent_thread_started_event_request( runtime: current_runtime_metadata(), model: input.model, ephemeral: input.ephemeral, - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 3554e570dd..772bb6c624 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -64,6 +64,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; @@ -147,7 +148,7 @@ enum MissingAnalyticsContext { #[derive(Clone)] struct ThreadMetadataState { - thread_source: Option<&'static str>, + thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, @@ -156,6 +157,7 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, + thread_source: Option, initialization_mode: ThreadInitializationMode, ) -> Self { let (subagent_source, parent_thread_id) = match session_source { @@ -172,7 +174,7 @@ impl ThreadMetadataState { | SessionSource::Unknown => (None, None), }; Self { - thread_source: session_source.thread_source_name(), + thread_source, initialization_mode, subagent_source, parent_thread_id, @@ -348,7 +350,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id, @@ -749,13 +751,16 @@ impl AnalyticsReducer { initialization_mode: ThreadInitializationMode, out: &mut Vec, ) { - let thread_source: SessionSource = thread.source.into(); + let session_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = - ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); + let thread_metadata = ThreadMetadataState::from_thread_metadata( + &session_source, + thread.thread_source.map(Into::into), + initialization_mode, + ); self.threads.insert( thread_id.clone(), ThreadAnalyticsState { @@ -857,7 +862,7 @@ impl AnalyticsReducer { accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), num_input_images: pending_request.num_input_images, @@ -1023,7 +1028,7 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, initialization_mode: thread_metadata.initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9ea9893f5b..75df89211d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3558,6 +3558,17 @@ }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -4032,6 +4043,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -4165,6 +4184,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index ae54b610f5..f5de1f456c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3088,6 +3088,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -4094,6 +4105,14 @@ ], "type": "object" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStartedNotification": { "properties": { "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a1519e4240..922ca49a5c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15232,6 +15232,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -15446,6 +15457,17 @@ }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -17126,6 +17148,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -17260,6 +17290,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5cecb71ed5..8382944c8e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13118,6 +13118,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -13332,6 +13343,17 @@ }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -15012,6 +15034,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -15146,6 +15176,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 970e2fe9ca..6419bc9422 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -137,6 +137,14 @@ "flex" ], "type": "string" + }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" } }, "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -232,6 +240,17 @@ }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 00689feda0..05f9849df3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1419,6 +1419,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -2117,6 +2128,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 4db2ae4642..aaeb3a5ab2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 003c75e597..6237f8b746 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 50147feca1..4a64c50252 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index ff774402b9..81eff83400 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1419,6 +1419,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -2117,6 +2128,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 75b08d53d6..f495e4cdfe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d5f0e9bfcc..aa5029afd9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -172,6 +172,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStartSource": { "enum": [ "startup", @@ -312,6 +320,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a0f39a29f6..b2d5cbfa08 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1419,6 +1419,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -2117,6 +2128,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index ff7c4a5320..98c163a41e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 1b5aa29683..5e26982d51 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -869,6 +869,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1567,6 +1578,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 8c4c9394bf..99e622565a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -4,6 +4,7 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GitInfo } from "./GitInfo"; import type { SessionSource } from "./SessionSource"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; @@ -52,6 +53,10 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional analytics source classification for this thread. + */ +threadSource: ThreadSource | null, /** * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index ba7119e9ed..ea67b491ad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -6,6 +6,7 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; /** * There are two ways to fork a thread: @@ -23,4 +24,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** + * Optional client-supplied analytics source classification for this forked thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts new file mode 100644 index 0000000000..8f55524801 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadSource = "user" | "subagent" | "memory_consolidation"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 374ac2e681..cecc183f92 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -7,10 +7,14 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStartSource } from "./ThreadStartSource"; export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** + * Optional client-supplied analytics source classification for this thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 547f0f1018..4998cdc710 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -396,6 +396,7 @@ export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; +export type { ThreadSource } from "./ThreadSource"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; export type { ThreadStartResponse } from "./ThreadStartResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5ab2e5ea01..9d425d8ab4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2184,6 +2184,7 @@ mod tests { cwd: cwd.clone(), cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -2226,6 +2227,7 @@ mod tests { "cwd": absolute_path_string("tmp"), "cliVersion": "0.0.0", "source": "exec", + "threadSource": null, "agentNickname": null, "agentRole": null, "gitInfo": null, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index ad125da4e6..578ef9193f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -6,6 +6,7 @@ use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; +use super::ThreadSource; use super::Turn; use super::TurnEnvironmentParams; use super::shared::v2_enum_from_core; @@ -134,6 +135,9 @@ pub struct ThreadStartParams { pub ephemeral: Option, #[ts(optional = nullable)] pub session_start_source: Option, + /// Optional client-supplied analytics source classification for this thread. + #[ts(optional = nullable)] + pub thread_source: Option, /// Optional sticky environments for this thread. /// /// Omitted selects the default environment when environment access is @@ -388,6 +392,9 @@ pub struct ThreadForkParams { pub developer_instructions: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ephemeral: bool, + /// Optional client-supplied analytics source classification for this forked thread. + #[ts(optional = nullable)] + pub thread_source: Option, /// When true, return only thread metadata and live fork state without /// populating `thread.turns`. This is useful when the client plans to call /// `thread/turns/list` immediately after forking. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 07c21e3906..cb02705849 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -4,6 +4,7 @@ use super::ThreadStatus; use super::TurnStatus; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadSource as CoreThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -60,6 +61,35 @@ impl From for CoreSessionSource { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl From for ThreadSource { + fn from(value: CoreThreadSource) -> Self { + match value { + CoreThreadSource::User => ThreadSource::User, + CoreThreadSource::Subagent => ThreadSource::Subagent, + CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation, + } + } +} + +impl From for CoreThreadSource { + fn from(value: ThreadSource) -> Self { + match value { + ThreadSource::User => CoreThreadSource::User, + ThreadSource::Subagent => CoreThreadSource::Subagent, + ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -98,6 +128,8 @@ pub struct Thread { pub cli_version: String, /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. pub agent_nickname: Option, /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 18fcc14091..6ff7f5f03f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2182,6 +2182,7 @@ mod tests { cwd: test_path_buf("/tmp").abs().into(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index ca7f8a9d4c..1c741944b5 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -307,6 +307,7 @@ impl ExternalAgentConfigRequestProcessor { config, initial_history: InitialHistory::Forked(rollout_items), session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a9c7ab90d1..321d1703dc 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -746,6 +746,7 @@ impl ThreadRequestProcessor { personality, ephemeral, session_start_source, + thread_source, environments, persist_extended_history, } = params; @@ -799,6 +800,7 @@ impl ThreadRequestProcessor { typesafe_overrides, dynamic_tools, session_start_source, + thread_source.map(Into::into), environment_selections, service_name, experimental_raw_events, @@ -882,6 +884,7 @@ impl ThreadRequestProcessor { typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, session_start_source: Option, + thread_source: Option, environments: Option>, service_name: Option, experimental_raw_events: bool, @@ -998,6 +1001,7 @@ impl ThreadRequestProcessor { codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, }, session_source: None, + thread_source, dynamic_tools: core_dynamic_tools, persist_extended_history: false, metrics_service_name: service_name, @@ -2382,6 +2386,11 @@ impl ThreadRequestProcessor { return Ok(()); } }; + thread.thread_source = codex_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); self.thread_watch_manager .upsert_thread(thread.clone()) @@ -2869,6 +2878,7 @@ impl ThreadRequestProcessor { base_instructions, developer_instructions, ephemeral, + thread_source, exclude_turns, persist_extended_history, } = params; @@ -2959,6 +2969,7 @@ impl ThreadRequestProcessor { history: history_items.clone(), rollout_path: source_thread.rollout_path.clone(), }), + thread_source.map(Into::into), /*persist_extended_history*/ false, self.request_trace_context(&request_id).await, ) @@ -3018,6 +3029,11 @@ impl ThreadRequestProcessor { } thread }; + thread.thread_source = forked_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); self.thread_watch_manager .upsert_thread_silently(thread.clone()) @@ -3620,6 +3636,7 @@ pub(crate) fn thread_from_stored_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + thread_source: thread.thread_source.map(Into::into), git_info, name: thread.name, turns: Vec::new(), @@ -3682,6 +3699,7 @@ fn summary_from_state_db_metadata( cwd: PathBuf, cli_version: String, source: String, + _thread_source: Option, agent_nickname: Option, agent_role: Option, git_sha: Option, @@ -3732,6 +3750,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar metadata.cwd.clone(), metadata.cli_version.clone(), metadata.source.clone(), + metadata.thread_source, metadata.agent_nickname.clone(), metadata.agent_role.clone(), metadata.git_sha.clone(), @@ -3815,6 +3834,7 @@ fn build_thread_from_snapshot( agent_nickname: config_snapshot.session_source.get_nickname(), agent_role: config_snapshot.session_source.get_agent_role(), source: config_snapshot.session_source.clone().into(), + thread_source: config_snapshot.thread_source.map(Into::into), git_info: None, name: None, turns: Vec::new(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 4f3e476e48..6bcbda478c 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -283,6 +283,7 @@ mod thread_processor_behavior_tests { cwd: PathBuf::from("/tmp"), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: Some(codex_protocol::protocol::ThreadSource::User), agent_nickname: None, agent_role: None, agent_path: None, @@ -540,6 +541,7 @@ mod thread_processor_behavior_tests { reasoning_effort: None, personality: None, session_source: SessionSource::Cli, + thread_source: None, }; assert_eq!( @@ -828,6 +830,7 @@ mod thread_processor_behavior_tests { agent_nickname: None, agent_role: None, }), + thread_source: Some(codex_protocol::protocol::ThreadSource::Subagent), agent_nickname: Some("atlas".to_string()), agent_role: Some("explorer".to_string()), model_provider: Some("test-provider".to_string()), @@ -849,6 +852,7 @@ mod thread_processor_behavior_tests { assert_eq!(thread.agent_nickname, Some("atlas".to_string())); assert_eq!(thread.agent_role, Some("explorer".to_string())); + assert_eq!(thread.thread_source, None); Ok(()) } @@ -975,6 +979,7 @@ mod thread_processor_behavior_tests { PathBuf::from("/"), "0.0.0".to_string(), source, + Some(codex_protocol::protocol::ThreadSource::Subagent), Some("atlas".to_string()), Some("explorer".to_string()), /*git_sha*/ None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index bb02affcc8..be3000e36a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -278,6 +278,7 @@ pub(crate) fn summary_to_thread( agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), + thread_source: None, git_info, name: None, turns: Vec::new(), diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 05997815ef..a3783f995d 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -904,6 +904,7 @@ impl TurnRequestProcessor { history: parent_history.items, rollout_path: parent_thread.rollout_path(), }), + /*thread_source*/ None, /*persist_extended_history*/ false, self.request_trace_context(request_id).await, ) diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index b1373c293d..47da7e8cad 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -902,6 +902,7 @@ mod tests { agent_nickname: None, agent_role: None, source, + thread_source: None, git_info: None, name: None, turns: Vec::new(), diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 06b273754c..6b2a9a0abe 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -138,6 +138,7 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -221,6 +222,7 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 862721a154..c6f95af95d 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -170,6 +170,7 @@ pub(crate) fn assert_basic_thread_initialized_event( thread_id: &str, expected_model: &str, initialization_mode: &str, + expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); assert_eq!( @@ -186,7 +187,10 @@ pub(crate) fn assert_basic_thread_initialized_event( ); assert_eq!(event["event_params"]["model"], expected_model); assert_eq!(event["event_params"]["ephemeral"], false); - assert_eq!(event["event_params"]["thread_source"], "user"); + assert_eq!( + event["event_params"]["thread_source"], + expected_thread_source + ); assert_eq!( event["event_params"]["subagent_source"], serde_json::Value::Null diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index b95adb9044..6b98e39b53 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -675,6 +675,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( personality: None, ephemeral: None, session_start_source: None, + thread_source: None, dynamic_tools: None, environments: None, mock_experimental_field: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index fd773f2e30..5c4ba3a3e3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -90,6 +91,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id.clone(), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -128,6 +130,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread_path.as_path(), original_path); assert!(thread.cwd.as_path().is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); assert_eq!(thread.name, None); assert_eq!( @@ -188,6 +191,13 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Some(&json!([])), "thread/started must not emit copied fork turns" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied fork origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; let mut expected_started_thread = thread; @@ -299,6 +309,7 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -403,6 +414,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -415,7 +427,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 0dc616dc86..0e9a7a9a05 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1119,6 +1119,7 @@ async fn seed_pathless_store_thread( thread_id, forked_from_id: None, source: ProtocolSessionSource::Cli, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 2e6665e091..4014e6f975 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -38,6 +38,7 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; @@ -240,15 +241,20 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let conversation_id = create_fake_rollout_with_text_elements( + let conversation_id = create_fake_rollout( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", - Vec::new(), Some("mock_provider"), /*git_info*/ None, )?; + set_thread_source_on_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + &conversation_id, + "user", + )?; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -265,10 +271,31 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { ) .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert_eq!(thread.thread_source, Some(ThreadSource::User)); let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed"); + assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_eq!(event["event_params"]["thread_source"], "user"); + Ok(()) +} + +fn set_thread_source_on_fake_rollout( + codex_home: &std::path::Path, + filename_ts: &str, + thread_id: &str, + thread_source: &str, +) -> Result<()> { + let path = rollout_path(codex_home, filename_ts, thread_id); + let contents = std::fs::read_to_string(&path)?; + let mut lines = contents.lines(); + let session_meta = lines + .next() + .ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?; + let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?; + session_meta["payload"]["thread_source"] = serde_json::json!(thread_source); + let remaining = lines.collect::>().join("\n"); + std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?; Ok(()) } @@ -1179,6 +1206,7 @@ stream_max_retries = 0 originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 2eba334774..68fc818ec2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -107,6 +108,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { let req_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -138,6 +140,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { "new persistent threads should not be ephemeral" ); assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); let thread_path = thread.path.clone().expect("thread path should be present"); assert!(thread_path.is_absolute(), "thread path should be absolute"); assert!( @@ -160,6 +163,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(false), "new persistent threads should serialize `ephemeral: false`" ); + assert_eq!( + thread_json.get("threadSource").and_then(Value::as_str), + Some("user"), + "new threads should serialize the caller-supplied thread origin" + ); assert_eq!(thread.name, None); // A corresponding thread/started notification should arrive. @@ -201,6 +209,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(false), "thread/started should serialize `ephemeral: false` for new persistent threads" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied thread origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); @@ -312,7 +327,10 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::User), + ..Default::default() + }) .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -324,7 +342,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 952d6069df..971b081ae5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::TextElement; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; @@ -116,6 +117,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -183,6 +185,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -464,6 +467,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 3c98fd15d7..e5967a6ed2 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -27,6 +27,7 @@ use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; use codex_thread_store::ReadThreadParams; @@ -234,6 +235,7 @@ impl AgentControl { config.clone(), self.clone(), session_source, + /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, /*metrics_service_name*/ None, inherited_shell_snapshot, @@ -420,6 +422,7 @@ impl AgentControl { InitialHistory::Forked(forked_rollout_items), self.clone(), session_source, + /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 6aae36d2e7..572cffcabe 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSource; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsEvent; @@ -84,6 +85,7 @@ pub(crate) async fn run_codex_thread_interactive( skills_watcher: Arc::clone(&parent_session.services.skills_watcher), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), + thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 3d1ce3ea76..2f74bd48b4 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; @@ -62,6 +63,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub thread_source: Option, } impl ThreadConfigSnapshot { diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 63faae418a..3de22ba1d3 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -73,6 +73,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 9c1eb3af4b..f36239f2ae 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -49,6 +49,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre cwd: PathBuf::from(cwd), cli_version: "test".to_string(), source: SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4bb57d3f7d..948678067e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -110,6 +110,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; @@ -391,6 +392,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) skills_watcher: Arc, pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, + pub(crate) thread_source: Option, pub(crate) agent_control: AgentControl, pub(crate) dynamic_tools: Vec, pub(crate) persist_extended_history: bool, @@ -453,6 +455,7 @@ impl Codex { skills_watcher, conversation_history, session_source, + thread_source, agent_control, dynamic_tools, persist_extended_history, @@ -612,6 +615,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + thread_source, dynamic_tools, persist_extended_history, inherited_shell_snapshot, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 67c1e33310..bd50d74bb6 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -103,7 +103,7 @@ pub(super) async fn spawn_review_thread( let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( sess.conversation_id.to_string(), - &session_source, + parent_turn_context.thread_source, review_turn_id.clone(), parent_turn_context.cwd.clone(), &parent_turn_context.permission_profile, @@ -123,6 +123,7 @@ pub(super) async fn spawn_review_thread( reasoning_effort, reasoning_summary, session_source, + thread_source: parent_turn_context.thread_source, environments: parent_turn_context.environments.clone(), tools_config, features: parent_turn_context.features.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 23621f87cf..d593544b49 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -2,6 +2,7 @@ use super::*; use crate::goals::GoalRuntimeState; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use tokio::sync::Semaphore; @@ -86,6 +87,8 @@ pub(crate) struct SessionConfiguration { pub(super) app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) pub(super) session_source: SessionSource, + /// Optional analytics source classification for this thread. + pub(super) thread_source: Option, pub(super) dynamic_tools: Vec, pub(super) persist_extended_history: bool, pub(super) inherited_shell_snapshot: Option>, @@ -141,6 +144,7 @@ impl SessionConfiguration { reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), + thread_source: self.thread_source, } } @@ -393,6 +397,7 @@ impl Session { thread_id: conversation_id, forked_from_id, source: session_source, + thread_source: session_configuration.thread_source, base_instructions: BaseInstructions { text: session_configuration.base_instructions.clone(), }, @@ -875,6 +880,7 @@ impl Session { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id, + thread_source: session_configuration.thread_source, thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f3a8d95107..3875f8fb57 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1722,6 +1722,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< usize::MAX, fork_config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -2411,6 +2412,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2514,6 +2516,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -2766,6 +2769,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { thread_id: session.conversation_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { @@ -2972,6 +2976,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3495,6 +3500,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3607,6 +3613,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -3829,6 +3836,7 @@ async fn make_session_with_config_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, inherited_shell_snapshot: None, @@ -4793,6 +4801,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { thread_id: session.conversation_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { @@ -5116,6 +5125,7 @@ where app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + thread_source: None, dynamic_tools, persist_extended_history: false, inherited_shell_snapshot: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index d7ec5937d2..8f8577e7fe 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -752,6 +752,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { session_source: SessionSource::SubAgent(SubAgentSource::Other( GUARDIAN_REVIEWER_NAME.to_string(), )), + thread_source: None, agent_control: AgentControl::default(), dynamic_tools: Vec::new(), persist_extended_history: false, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 00ff985861..e42fc2b102 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -5,6 +5,7 @@ use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; @@ -63,6 +64,7 @@ pub(crate) struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, + pub(crate) thread_source: Option, pub(crate) environments: ResolvedTurnEnvironments, /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path @@ -248,6 +250,7 @@ impl TurnContext { reasoning_effort, reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), + thread_source: self.thread_source, environments: self.environments.clone(), cwd: self.cwd.clone(), current_date: self.current_date.clone(), @@ -520,7 +523,7 @@ impl Session { let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), - &session_source, + session_configuration.thread_source, sub_id.clone(), cwd.clone(), &session_configuration.permission_profile(), @@ -540,6 +543,7 @@ impl Session { reasoning_effort, reasoning_summary, session_source, + thread_source: session_configuration.thread_source, environments, cwd, current_date: Some(current_date), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 06f8118db7..03a3ec92fc 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -48,6 +48,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnEnvironmentSelection; @@ -219,6 +220,7 @@ pub struct StartThreadOptions { pub config: Config, pub initial_history: InitialHistory, pub session_source: Option, + pub thread_source: Option, pub dynamic_tools: Vec, pub persist_extended_history: bool, pub metrics_service_name: Option, @@ -620,6 +622,7 @@ impl ThreadManager { config, initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools, persist_extended_history, metrics_service_name: None, @@ -636,12 +639,16 @@ impl ThreadManager { let session_source = options .session_source .unwrap_or_else(|| self.state.session_source.clone()); + let thread_source = options + .thread_source + .or_else(|| options.initial_history.get_resumed_thread_source()); Box::pin(self.state.spawn_thread_with_source( options.config, options.initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), session_source, + thread_source, options.dynamic_tools, options.persist_extended_history, options.metrics_service_name, @@ -684,11 +691,13 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.state.spawn_thread( config, initial_history, auth_manager, self.agent_control(), + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -713,6 +722,7 @@ impl ThreadManager { InitialHistory::New, Arc::clone(&self.state.auth_manager), self.agent_control(), + /*thread_source*/ None, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -735,11 +745,13 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.state.spawn_thread( config, initial_history, auth_manager, self.agent_control(), + thread_source, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -817,6 +829,7 @@ impl ThreadManager { snapshot: S, config: Config, path: PathBuf, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult @@ -829,6 +842,7 @@ impl ThreadManager { snapshot, config, history, + thread_source, persist_extended_history, parent_trace, ) @@ -841,6 +855,7 @@ impl ThreadManager { snapshot: S, config: Config, history: InitialHistory, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult @@ -851,6 +866,7 @@ impl ThreadManager { snapshot.into(), config, history, + thread_source, persist_extended_history, parent_trace, ) @@ -862,6 +878,7 @@ impl ThreadManager { snapshot: ForkSnapshot, config: Config, history: InitialHistory, + thread_source: Option, persist_extended_history: bool, parent_trace: Option, ) -> CodexResult { @@ -876,6 +893,7 @@ impl ThreadManager { history, Arc::clone(&self.state.auth_manager), self.agent_control(), + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -991,6 +1009,7 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*metrics_service_name*/ None, /*inherited_shell_snapshot*/ None, @@ -1006,6 +1025,7 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, persist_extended_history: bool, metrics_service_name: Option, inherited_shell_snapshot: Option>, @@ -1021,6 +1041,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), persist_extended_history, metrics_service_name, @@ -1047,12 +1068,14 @@ impl ThreadManagerState { } = options; let environments = default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd); + let thread_source = initial_history.get_resumed_thread_source(); Box::pin(self.spawn_thread_with_source( config, initial_history, Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -1072,6 +1095,7 @@ impl ThreadManagerState { initial_history: InitialHistory, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, persist_extended_history: bool, inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, @@ -1086,6 +1110,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, @@ -1106,6 +1131,7 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, @@ -1119,6 +1145,7 @@ impl ThreadManagerState { auth_manager, agent_control, self.session_source.clone(), + thread_source, dynamic_tools, persist_extended_history, metrics_service_name, @@ -1139,6 +1166,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, + thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, @@ -1202,6 +1230,7 @@ impl ThreadManagerState { skills_watcher: Arc::clone(&self.skills_watcher), conversation_history: initial_history, session_source, + thread_source, agent_control, dynamic_tools, persist_extended_history, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 643309aac1..a61a3a3545 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathBufExt; @@ -178,6 +179,7 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() { usize::MAX, config, path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ); @@ -333,6 +335,7 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d config: config.clone(), initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -370,6 +373,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { session_source: Some(SessionSource::Internal( InternalSessionSource::MemoryConsolidation, )), + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -424,6 +428,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { config: config.clone(), initial_history: InitialHistory::New, session_source: None, + thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, @@ -480,6 +485,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ForkSnapshot::Interrupted, config, rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -620,6 +626,86 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { .expect("shutdown resumed thread"); } +#[tokio::test] +async fn resume_stopped_thread_from_rollout_preserves_thread_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + ); + + let source = manager + .start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: Some(ThreadSource::User), + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + }) + .await + .expect("start source thread"); + source.thread.ensure_rollout_materialized().await; + source + .thread + .flush_rollout() + .await + .expect("flush source rollout"); + let rollout_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + source + .thread + .shutdown_and_wait() + .await + .expect("shutdown source thread before resume"); + let _ = manager.remove_thread(&source.thread_id).await; + + let resumed = manager + .resume_thread_from_rollout( + config, + rollout_path, + auth_manager, + /*parent_trace*/ None, + ) + .await + .expect("resume source thread"); + + assert_eq!( + resumed + .thread + .config_snapshot() + .await + .thread_source + .as_ref(), + Some(&ThreadSource::User) + ); + + resumed + .thread + .shutdown_and_wait() + .await + .expect("shutdown resumed thread"); +} + #[tokio::test] async fn new_uses_active_provider_for_model_refresh() { let server = MockServer::start().await; @@ -891,6 +977,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1007,6 +1094,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1088,6 +1176,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ForkSnapshot::Interrupted, config.clone(), source_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -1128,6 +1217,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ ForkSnapshot::Interrupted, config.clone(), forked_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 11da058b53..d4a46944eb 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -17,7 +17,7 @@ use codex_git_utils::get_head_commit_hash; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; @@ -69,7 +69,7 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - thread_source: Option<&'static str>, + thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -115,7 +115,7 @@ fn merge_turn_metadata( fn build_turn_metadata_bag( session_id: Option, - thread_source: Option<&'static str>, + thread_source: Option, turn_id: Option, sandbox: Option, repo_root: Option, @@ -187,7 +187,7 @@ pub(crate) struct TurnMetadataState { impl TurnMetadataState { pub(crate) fn new( session_id: String, - session_source: &SessionSource, + thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, permission_profile: &PermissionProfile, @@ -205,7 +205,7 @@ impl TurnMetadataState { ); let base_metadata = build_turn_metadata_bag( Some(session_id), - session_source.thread_source_name(), + thread_source, Some(turn_id), sandbox, /*repo_root*/ None, diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index d16ed27fb4..d840ade91b 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -4,8 +4,7 @@ use crate::sandbox_tags::sandbox_tag; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; @@ -95,7 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -117,15 +116,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { } #[test] -fn turn_metadata_state_classifies_subagent_thread_source() { +fn turn_metadata_state_uses_explicit_subagent_thread_source() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); - let session_source = SessionSource::SubAgent(SubAgentSource::Review); - let state = TurnMetadataState::new( "session-a".to_string(), - &session_source, + Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, &permission_profile, @@ -148,7 +145,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -174,7 +171,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -218,7 +215,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, @@ -244,7 +241,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let state = TurnMetadataState::new( "session-a".to_string(), - &SessionSource::Exec, + Some(ThreadSource::User), "turn-a".to_string(), cwd, &permission_profile, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 56e9893116..893d38ed83 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -452,7 +452,7 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() initial_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); let git_config_global = cwd.join("empty-git-config"); @@ -565,13 +565,13 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() first_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); assert_eq!( second_parsed .get("thread_source") .and_then(serde_json::Value::as_str), - Some("user") + None ); assert_eq!( first_turn_id, second_turn_id, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 354e9a6a03..2788c8ef21 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -855,6 +855,7 @@ async fn fork_thread( nth_user_message, config.clone(), path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, )) diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 19ed2a2088..91d321b540 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -101,6 +101,7 @@ async fn fork_thread_twice_drops_to_first_message() { ForkSnapshot::TruncateBeforeNthUserMessage(1), config_for_fork.clone(), base_path.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -125,6 +126,7 @@ async fn fork_thread_twice_drops_to_first_message() { ForkSnapshot::TruncateBeforeNthUserMessage(0), config_for_fork.clone(), fork1_path.clone(), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) @@ -197,6 +199,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { history: source_items.clone(), rollout_path: None, }), + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index bb93d5cbf8..4d6259a599 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -497,6 +497,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { ForkSnapshot::Interrupted, fork_config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 8d33ba1705..25415cd7ee 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -89,6 +89,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -134,6 +135,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re originator: "test_originator".to_string(), cli_version: "test_version".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index d0b39a9283..51b3f2db1a 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -186,6 +186,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 8250f5493d..20fa8a8f0e 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -144,6 +144,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index de52821839..d0e207d963 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -72,6 +72,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( /*snapshot*/ 0usize, resumed.config.clone(), rollout_path, + /*thread_source*/ None, /*persist_extended_history*/ false, /*parent_trace*/ None, ) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d61346f1d0..0db63b8034 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1134,6 +1134,7 @@ fn session_configured_from_thread_response( Ok(SessionConfiguredEvent { session_id, forked_from_id: None, + thread_source: None, thread_name, model, model_provider_id, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 094da6f936..b35f6e9bfa 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -255,6 +255,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0-test".to_string(), source: codex_app_server_protocol::SessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -477,6 +478,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 4b01ccccd1..e066cb6ddc 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -108,6 +108,7 @@ fn session_configured_produces_thread_started_event() { session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") .expect("thread id should parse"), forked_from_id: None, + thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index eb66ea0619..1ab6b1a0af 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -298,6 +298,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), @@ -343,6 +344,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), @@ -411,6 +413,7 @@ mod tests { let session_configured_event = SessionConfiguredEvent { session_id: thread_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 737fb67870..5db8761058 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_rollout_trace::InferenceTraceContext; @@ -241,6 +242,7 @@ impl MemoryStartupContext { session_source: Some(SessionSource::Internal( InternalSessionSource::MemoryConsolidation, )), + thread_source: Some(ThreadSource::MemoryConsolidation), dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e8f4a80d6a..09be839f93 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2503,6 +2503,18 @@ impl InitialHistory { }), } } + + pub fn get_resumed_thread_source(&self) -> Option { + match self { + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source, + _ => None, + }) + } + } + } } fn session_cwd_from_items(items: &[RolloutItem]) -> Option { @@ -2528,6 +2540,44 @@ pub enum SessionSource { Unknown, } +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl ThreadSource { + pub fn as_str(self) -> &'static str { + match self { + ThreadSource::User => "user", + ThreadSource::Subagent => "subagent", + ThreadSource::MemoryConsolidation => "memory_consolidation", + } + } +} + +impl fmt::Display for ThreadSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ThreadSource { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "user" => Ok(ThreadSource::User), + "subagent" => Ok(ThreadSource::Subagent), + "memory_consolidation" => Ok(ThreadSource::MemoryConsolidation), + other => Err(format!("unknown thread source: {other}")), + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -2588,16 +2638,6 @@ impl SessionSource { }) } - /// Low cardinality thread source label for analytics. - pub fn thread_source_name(&self) -> Option<&'static str> { - match self { - SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), - SessionSource::Internal(_) => Some("internal"), - SessionSource::SubAgent(_) => Some("subagent"), - SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, - } - } - pub fn is_internal(&self) -> bool { matches!(self, SessionSource::Internal(_)) } @@ -2698,6 +2738,9 @@ pub struct SessionMeta { pub cli_version: String, #[serde(default)] pub source: SessionSource, + /// Optional analytics source classification for this thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. #[serde(skip_serializing_if = "Option::is_none")] pub agent_nickname: Option, @@ -2728,6 +2771,7 @@ impl Default for SessionMeta { originator: String::new(), cli_version: String::new(), source: SessionSource::default(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -3415,6 +3459,9 @@ pub struct SessionConfiguredEvent { pub session_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + /// Optional analytics source classification for this thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_source: Option, /// Optional user-facing thread name (may be unset). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3486,6 +3533,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { session_id: ThreadId, forked_from_id: Option, #[serde(default)] + thread_source: Option, + #[serde(default)] thread_name: Option, model: String, model_provider_id: String, @@ -3524,6 +3573,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { Ok(Self { session_id: wire.session_id, forked_from_id: wire.forked_from_id, + thread_source: wire.thread_source, thread_name: wire.thread_name, model: wire.model, model_provider_id: wire.model_provider_id, @@ -4011,28 +4061,6 @@ mod tests { ); } - #[test] - fn session_source_thread_source_name_classifies_user_and_subagent_sources() { - for (source, expected) in [ - (SessionSource::Cli, Some("user")), - (SessionSource::VSCode, Some("user")), - (SessionSource::Exec, Some("user")), - ( - SessionSource::Internal(InternalSessionSource::MemoryConsolidation), - Some("internal"), - ), - ( - SessionSource::SubAgent(SubAgentSource::Review), - Some("subagent"), - ), - (SessionSource::Mcp, None), - (SessionSource::Custom("atlas".to_string()), None), - (SessionSource::Unknown, None), - ] { - assert_eq!(source.thread_source_name(), expected); - } - } - #[test] fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() { assert_eq!( @@ -5274,6 +5302,7 @@ mod tests { msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, forked_from_id: None, + thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index c94cd0be7e..45db758c65 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -40,6 +40,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -91,6 +92,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -350,6 +352,7 @@ fn write_rollout_in_sessions_with_cwd( originator: "cli".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::default(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 512c223263..7a5e28a5a3 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -61,6 +61,7 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; use codex_utils_path as path_utils; @@ -88,6 +89,7 @@ pub enum RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, event_persistence_mode: EventPersistenceMode, @@ -164,6 +166,7 @@ impl RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, event_persistence_mode: EventPersistenceMode, @@ -172,6 +175,7 @@ impl RolloutRecorderParams { conversation_id, forked_from_id, source, + thread_source, base_instructions, dynamic_tools, event_persistence_mode, @@ -667,6 +671,7 @@ impl RolloutRecorder { conversation_id, forked_from_id, source, + thread_source, base_instructions, dynamic_tools, event_persistence_mode, @@ -695,6 +700,7 @@ impl RolloutRecorder { agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), source, + thread_source, model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), dynamic_tools: if dynamic_tools.is_empty() { diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 5711c47bad..35018b657d 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -91,6 +91,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { originator: "test".to_string(), cli_version: "test".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -306,6 +307,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, @@ -386,6 +388,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i thread_id, /*forked_from_id*/ None, SessionSource::Exec, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, @@ -485,6 +488,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu thread_id, /*forked_from_id*/ None, SessionSource::Cli, + /*thread_source*/ None, BaseInstructions::default(), Vec::new(), EventPersistenceMode::Limited, diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index fbef7eb4f9..757b08b4d4 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -32,6 +32,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index b5c2790dae..fdfed3dadc 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1214,6 +1214,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { originator: "test_originator".into(), cli_version: "test_version".into(), source: SessionSource::VSCode, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/state/migrations/0030_threads_thread_source.sql b/codex-rs/state/migrations/0030_threads_thread_source.sql new file mode 100644 index 0000000000..4f11c9a3f8 --- /dev/null +++ b/codex-rs/state/migrations/0030_threads_thread_source.sql @@ -0,0 +1 @@ +ALTER TABLE threads ADD COLUMN thread_source TEXT; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index dcb730d6c6..723c5084eb 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -48,6 +48,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi } metadata.id = meta_line.meta.id; metadata.source = enum_to_string(&meta_line.meta.source); + metadata.thread_source = meta_line.meta.thread_source; metadata.agent_nickname = meta_line.meta.agent_nickname.clone(); metadata.agent_role = meta_line.meta.agent_role.clone(); metadata.agent_path = meta_line.meta.agent_path.clone(); @@ -249,6 +250,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -382,6 +384,7 @@ mod tests { originator: "codex_cli_rs".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -408,6 +411,7 @@ mod tests { created_at, updated_at: created_at, source: "cli".to_string(), + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index bddb2fb364..e5e2d1d1f8 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use sqlx::Row; use sqlx::sqlite::SqliteRow; use std::path::PathBuf; @@ -68,6 +69,8 @@ pub struct ThreadMetadata { pub updated_at: DateTime, /// The session source (stringified enum). pub source: String, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. pub agent_nickname: Option, /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. @@ -117,6 +120,8 @@ pub struct ThreadMetadataBuilder { pub updated_at: Option>, /// The session source. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random unique nickname assigned to the session. pub agent_nickname: Option, /// Optional role (agent_role) assigned to the session. @@ -157,6 +162,7 @@ impl ThreadMetadataBuilder { created_at, updated_at: None, source, + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -188,6 +194,7 @@ impl ThreadMetadataBuilder { created_at, updated_at, source, + thread_source: self.thread_source, agent_nickname: self.agent_nickname.clone(), agent_role: self.agent_role.clone(), agent_path: self @@ -313,6 +320,7 @@ pub(crate) struct ThreadRow { created_at: i64, updated_at: i64, source: String, + thread_source: Option, agent_nickname: Option, agent_role: Option, agent_path: Option, @@ -340,6 +348,7 @@ impl ThreadRow { created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, source: row.try_get("source")?, + thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, agent_role: row.try_get("agent_role")?, agent_path: row.try_get("agent_path")?, @@ -371,6 +380,7 @@ impl TryFrom for ThreadMetadata { created_at, updated_at, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -389,12 +399,17 @@ impl TryFrom for ThreadMetadata { git_branch, git_origin_url, } = row; + let thread_source = thread_source + .map(|thread_source| thread_source.parse()) + .transpose() + .map_err(anyhow::Error::msg)?; Ok(Self { id: ThreadId::try_from(id)?, rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -480,6 +495,7 @@ mod tests { created_at: 1_700_000_000, updated_at: 1_700_000_100, source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, @@ -508,6 +524,7 @@ mod tests { created_at: DateTime::::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 5b75225b1e..186f2dd341 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -137,6 +137,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_path, threads.agent_nickname, threads.agent_role, diff --git a/codex-rs/state/src/runtime/test_support.rs b/codex-rs/state/src/runtime/test_support.rs index 5f07336853..aa1785ba7d 100644 --- a/codex-rs/state/src/runtime/test_support.rs +++ b/codex-rs/state/src/runtime/test_support.rs @@ -48,6 +48,7 @@ pub(super) fn test_thread_metadata( created_at: now, updated_at: now, source: "cli".to_string(), + thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 5188bc3bc0..1795a864d8 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -13,6 +13,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_nickname, threads.agent_role, threads.agent_path, @@ -486,6 +487,7 @@ INSERT INTO threads ( created_at_ms, updated_at_ms, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -505,7 +507,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING "#, ) @@ -516,6 +518,11 @@ ON CONFLICT(id) DO NOTHING .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) .bind(metadata.source.as_str()) + .bind( + metadata + .thread_source + .map(codex_protocol::protocol::ThreadSource::as_str), + ) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) @@ -683,6 +690,7 @@ INSERT INTO threads ( created_at_ms, updated_at_ms, source, + thread_source, agent_nickname, agent_role, agent_path, @@ -702,7 +710,7 @@ INSERT INTO threads ( git_branch, git_origin_url, memory_mode -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET rollout_path = excluded.rollout_path, created_at = excluded.created_at, @@ -710,6 +718,7 @@ ON CONFLICT(id) DO UPDATE SET created_at_ms = excluded.created_at_ms, updated_at_ms = excluded.updated_at_ms, source = excluded.source, + thread_source = excluded.thread_source, agent_nickname = excluded.agent_nickname, agent_role = excluded.agent_role, agent_path = excluded.agent_path, @@ -737,6 +746,11 @@ ON CONFLICT(id) DO UPDATE SET .bind(datetime_to_epoch_millis(metadata.created_at)) .bind(datetime_to_epoch_millis(updated_at)) .bind(metadata.source.as_str()) + .bind( + metadata + .thread_source + .map(codex_protocol::protocol::ThreadSource::as_str), + ) .bind(metadata.agent_nickname.as_deref()) .bind(metadata.agent_role.as_deref()) .bind(metadata.agent_path.as_deref()) @@ -958,6 +972,7 @@ SELECT threads.created_at_ms AS created_at, threads.updated_at_ms AS updated_at, threads.source, + threads.thread_source, threads.agent_nickname, threads.agent_role, threads.agent_path, @@ -1361,6 +1376,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -1419,6 +1435,7 @@ mod tests { originator: String::new(), cli_version: String::new(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index c54ecb4af2..fca3d21e62 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -272,6 +272,7 @@ fn stored_thread_from_state( cwd: PathBuf::new(), cli_version: "test".to_string(), source: created.source.clone(), + thread_source: created.thread_source, agent_nickname: None, agent_role: None, agent_path: None, diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index cb0f64ea9c..44938e6e8e 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -34,6 +34,7 @@ pub(super) async fn create_thread( params.thread_id, params.forked_from_id, params.source, + params.thread_source, params.base_instructions, params.dynamic_tools, event_persistence_mode(params.event_persistence_mode), diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 0cbf94da8c..bb46287123 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -130,6 +130,7 @@ pub(super) fn stored_thread_from_rollout_item( cwd: item.cwd.unwrap_or_default(), cli_version: item.cli_version.unwrap_or_default(), source, + thread_source: None, agent_nickname: item.agent_nickname, agent_role: item.agent_role, agent_path: None, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index e6ea63bba1..8872442073 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -744,6 +744,7 @@ mod tests { thread_id, forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: thread_metadata(), diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 5bcf411beb..dd6e8494ab 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -274,10 +274,11 @@ async fn stored_thread_from_sqlite_metadata( .ok() .flatten(), }; - let forked_from_id = read_session_meta_line(metadata.rollout_path.as_path()) + let session_meta = read_session_meta_line(metadata.rollout_path.as_path()) .await .ok() - .and_then(|meta_line| meta_line.meta.forked_from_id); + .map(|meta_line| meta_line.meta); + let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id); StoredThread { thread_id: metadata.id, rollout_path: Some(metadata.rollout_path), @@ -297,6 +298,7 @@ async fn stored_thread_from_sqlite_metadata( cwd: metadata.cwd, cli_version: metadata.cli_version, source: parse_session_source(&metadata.source), + thread_source: metadata.thread_source, agent_nickname: metadata.agent_nickname, agent_role: metadata.agent_role, agent_path: metadata.agent_path, @@ -362,6 +364,7 @@ fn stored_thread_from_meta_line( cwd: meta_line.meta.cwd, cli_version: meta_line.meta.cli_version, source: meta_line.meta.source, + thread_source: meta_line.meta.thread_source, agent_nickname: meta_line.meta.agent_nickname, agent_role: meta_line.meta.agent_role, agent_path: meta_line.meta.agent_path, diff --git a/codex-rs/thread-store/src/remote/helpers.rs b/codex-rs/thread-store/src/remote/helpers.rs index 74b3ac7763..3322ecd142 100644 --- a/codex-rs/thread-store/src/remote/helpers.rs +++ b/codex-rs/thread-store/src/remote/helpers.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; use super::proto; use crate::GitInfoPatch; @@ -296,6 +297,11 @@ pub(super) fn stored_thread_from_proto( cwd: PathBuf::from(thread.cwd), cli_version: thread.cli_version, source, + thread_source: thread + .thread_source + .map(|thread_source| thread_source.parse::()) + .transpose() + .map_err(|error| ThreadStoreError::Internal { message: error })?, agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, agent_path: thread.agent_path, @@ -340,6 +346,7 @@ pub(super) fn stored_thread_to_proto(thread: StoredThread) -> proto::StoredThrea cwd: thread.cwd.to_string_lossy().into_owned(), cli_version: thread.cli_version, source: Some(proto_session_source(&thread.source)), + thread_source: thread.thread_source.map(|source| source.to_string()), git_info: thread.git_info.map(git_info_to_proto), agent_nickname: thread.agent_nickname, agent_role: thread.agent_role, diff --git a/codex-rs/thread-store/src/remote/list_threads.rs b/codex-rs/thread-store/src/remote/list_threads.rs index 7fb0da6dc3..cf562497f4 100644 --- a/codex-rs/thread-store/src/remote/list_threads.rs +++ b/codex-rs/thread-store/src/remote/list_threads.rs @@ -140,6 +140,7 @@ mod tests { kind: proto::SessionSourceKind::Cli.into(), ..Default::default() }), + thread_source: Some("user".to_string()), git_info: Some(proto::GitInfo { sha: Some("abc123".to_string()), branch: Some("main".to_string()), @@ -250,6 +251,7 @@ mod tests { sub_agent_role: Some("explorer".to_string()), ..Default::default() }), + thread_source: Some("subagent".to_string()), git_info: Some(proto::GitInfo { sha: Some("abc123".to_string()), branch: Some("main".to_string()), diff --git a/codex-rs/thread-store/src/remote/mod.rs b/codex-rs/thread-store/src/remote/mod.rs index 3e74a45f4b..013b74c933 100644 --- a/codex-rs/thread-store/src/remote/mod.rs +++ b/codex-rs/thread-store/src/remote/mod.rs @@ -358,6 +358,7 @@ mod tests { thread_id: ThreadId::new(), forked_from_id: None, source: SessionSource::Exec, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: metadata.clone(), diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto index 7c797f139a..a5755afee0 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto @@ -132,6 +132,7 @@ message StoredThread { optional string sandbox_policy_json = 21; optional string token_usage_json = 22; optional StoredThreadHistory history = 23; + optional string thread_source = 24; } message SessionSource { diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs index a210ef8766..c5b8fdc1d6 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs @@ -168,6 +168,8 @@ pub struct StoredThread { pub token_usage_json: ::core::option::Option<::prost::alloc::string::String>, #[prost(message, optional, tag = "23")] pub history: ::core::option::Option, + #[prost(string, optional, tag = "24")] + pub thread_source: ::core::option::Option<::prost::alloc::string::String>, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct SessionSource { diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 85bde023bd..06aa2998c9 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Deserialize; use serde::Serialize; @@ -48,6 +49,8 @@ pub struct CreateThreadParams { pub forked_from_id: Option, /// Runtime source for the thread. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Base instructions persisted in session metadata. pub base_instructions: BaseInstructions, /// Dynamic tools available to the thread at startup. @@ -211,6 +214,8 @@ pub struct StoredThread { pub cli_version: String, /// Runtime source for the thread. pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, /// Optional random nickname for thread-spawn sub-agents. pub agent_nickname: Option, /// Optional role for thread-spawn sub-agents. diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index c98a54180c..49e5568596 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -129,6 +129,7 @@ mod tests { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0e59964c3b..bb00b53533 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2871,6 +2871,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, @@ -2952,6 +2953,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ cwd: test_path_buf("/tmp/agent").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: Some("Robie".to_string()), agent_role: Some("explorer".to_string()), git_info: None, @@ -3006,6 +3008,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -4969,6 +4972,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 3a898b82a3..ac6f6311ca 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -333,6 +333,7 @@ mod tests { cwd: test_path_buf("/tmp/read").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Unknown, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 67e4869868..9aafe9aed3 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1826,6 +1826,7 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 06ad0a61a7..171a1c3513 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5695,6 +5695,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -5727,6 +5728,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -5792,6 +5794,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -5847,6 +5850,7 @@ session_picker_view = "dense" cwd: test_path_buf("/tmp").abs(), cli_version: String::from("0.0.0"), source: codex_app_server_protocol::SessionSource::Cli, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None,