diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bf76650bf0..4d173d2030 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1366,16 +1366,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "codex-account" -version = "0.0.0" -dependencies = [ - "anyhow", - "codex-backend-client", - "codex-login", - "thiserror 2.0.18", -] - [[package]] name = "codex-analytics" version = "0.0.0" @@ -1908,7 +1898,6 @@ dependencies = [ "bm25", "chrono", "clap", - "codex-account", "codex-analytics", "codex-api", "codex-app-server-protocol", @@ -2942,6 +2931,7 @@ name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ "dirs", + "dunce", "pretty_assertions", "schemars 0.8.22", "serde", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 31dfeaf51b..32ae50bfb7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,5 @@ [workspace] members = [ - "account", "analytics", "backend-client", "ansi-escape", @@ -105,7 +104,6 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } -codex-account = { path = "account" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-app-server = { path = "app-server" } diff --git a/codex-rs/account/BUILD.bazel b/codex-rs/account/BUILD.bazel deleted file mode 100644 index b35b3c390d..0000000000 --- a/codex-rs/account/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "account", - crate_name = "codex_account", -) diff --git a/codex-rs/account/Cargo.toml b/codex-rs/account/Cargo.toml deleted file mode 100644 index f1b8f8e8be..0000000000 --- a/codex-rs/account/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "codex-account" -version.workspace = true -edition.workspace = true -license.workspace = true -publish = false - -[lib] -name = "codex_account" -path = "src/lib.rs" - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -codex-backend-client = { workspace = true } -codex-login = { workspace = true } -thiserror = { workspace = true } diff --git a/codex-rs/account/src/lib.rs b/codex-rs/account/src/lib.rs deleted file mode 100644 index 02368eafe8..0000000000 --- a/codex-rs/account/src/lib.rs +++ /dev/null @@ -1,121 +0,0 @@ -use codex_backend_client::Client as BackendClient; -use codex_backend_client::RequestError; -use codex_backend_client::WorkspaceRole as BackendWorkspaceRole; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use thiserror::Error; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WorkspaceRole { - AccountOwner, - AccountAdmin, - StandardUser, -} - -impl WorkspaceRole { - fn is_workspace_owner(self) -> bool { - matches!(self, Self::AccountOwner | Self::AccountAdmin) - } -} - -impl From for WorkspaceRole { - fn from(value: BackendWorkspaceRole) -> Self { - match value { - BackendWorkspaceRole::AccountOwner => Self::AccountOwner, - BackendWorkspaceRole::AccountAdmin => Self::AccountAdmin, - BackendWorkspaceRole::StandardUser => Self::StandardUser, - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct WorkspaceOwnership { - pub workspace_role: Option, - pub is_workspace_owner: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Debug, Error)] -pub enum SendAddCreditsNudgeEmailError { - #[error("codex account authentication required to notify workspace owner")] - AuthRequired, - - #[error("chatgpt authentication required to notify workspace owner")] - ChatGptAuthRequired, - - #[error("failed to construct backend client: {0}")] - CreateClient(#[from] anyhow::Error), - - #[error("failed to notify workspace owner: {0}")] - Request(#[from] RequestError), -} - -pub async fn send_add_credits_nudge_email( - chatgpt_base_url: impl Into, - auth_manager: &AuthManager, -) -> Result { - let auth = auth_manager - .auth() - .await - .ok_or(SendAddCreditsNudgeEmailError::AuthRequired)?; - send_add_credits_nudge_email_for_auth(chatgpt_base_url, &auth).await -} - -pub async fn send_add_credits_nudge_email_for_auth( - chatgpt_base_url: impl Into, - auth: &CodexAuth, -) -> Result { - if !auth.is_chatgpt_auth() { - return Err(SendAddCreditsNudgeEmailError::ChatGptAuthRequired); - } - - let client = BackendClient::from_auth(chatgpt_base_url, auth)?; - match client.send_add_credits_nudge_email().await { - Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), - Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { - Ok(AddCreditsNudgeEmailStatus::CooldownActive) - } - Err(err) => Err(err.into()), - } -} - -pub async fn resolve_workspace_role_and_owner_for_auth( - chatgpt_base_url: &str, - auth: Option<&CodexAuth>, -) -> WorkspaceOwnership { - let token_is_workspace_owner = auth.and_then(CodexAuth::is_workspace_owner); - let Some(auth) = auth else { - return WorkspaceOwnership::default(); - }; - - let workspace_role = fetch_current_workspace_role_for_auth(chatgpt_base_url, auth).await; - let is_workspace_owner = workspace_role - .map(WorkspaceRole::is_workspace_owner) - .or(token_is_workspace_owner); - WorkspaceOwnership { - workspace_role, - is_workspace_owner, - } -} - -async fn fetch_current_workspace_role_for_auth( - chatgpt_base_url: &str, - auth: &CodexAuth, -) -> Option { - if !auth.is_chatgpt_auth() { - return None; - } - - let client = BackendClient::from_auth(chatgpt_base_url.to_string(), auth).ok()?; - client - .get_current_workspace_role() - .await - .ok() - .flatten() - .map(WorkspaceRole::from) -} diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml index 2239c4bc36..0f36373145 100644 --- a/codex-rs/analytics/Cargo.toml +++ b/codex-rs/analytics/Cargo.toml @@ -20,6 +20,7 @@ codex-plugin = { workspace = true } codex-protocol = { workspace = true } os_info = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } sha1 = { workspace = true } tokio = { workspace = true, features = [ "macros", @@ -29,4 +30,3 @@ tracing = { workspace = true, features = ["log"] } [dev-dependencies] pretty_assertions = { workspace = true } -serde_json = { workspace = true } diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef9c42cb12..0d96ee6061 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -1,4 +1,5 @@ use crate::events::AppServerRpcTransport; +use crate::events::GuardianReviewEventParams; use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; @@ -151,6 +152,12 @@ impl AnalyticsEventsClient { )); } + pub fn track_guardian_review(&self, input: GuardianReviewEventParams) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( + Box::new(input), + ))); + } + pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { if mentions.is_empty() { return; diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 97ebe1f046..618dd8ffeb 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -6,6 +6,9 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::approvals::NetworkApprovalProtocol; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxPermissions; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -36,6 +39,7 @@ pub(crate) struct TrackEventsRequest { pub(crate) enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), ThreadInitialized(ThreadInitializedEvent), + GuardianReview(Box), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), Compaction(Box), @@ -101,6 +105,179 @@ pub(crate) struct ThreadInitializedEvent { pub(crate) event_params: ThreadInitializedEventParams, } +#[derive(Serialize)] +pub(crate) struct GuardianReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: GuardianReviewEventPayload, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewDecision { + Approved, + Denied, + Aborted, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewTerminalStatus { + Approved, + Denied, + Aborted, + TimedOut, + FailedClosed, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewFailureReason { + Timeout, + Cancelled, + PromptBuildError, + SessionError, + ParseError, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianReviewSessionKind { + TrunkNew, + TrunkReused, + EphemeralForked, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GuardianReviewRiskLevel { + Low, + Medium, + High, + Critical, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GuardianReviewUserAuthorization { + Unknown, + Low, + Medium, + High, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GuardianReviewOutcome { + Allow, + Deny, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianApprovalRequestSource { + /// Approval requested directly by the main Codex turn. + MainTurn, + /// Approval requested by a delegated subagent and routed through the parent + /// session for guardian review. + DelegatedSubagent, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GuardianReviewedAction { + Shell { + command: Vec, + command_display: String, + cwd: String, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, + justification: Option, + }, + UnifiedExec { + command: Vec, + command_display: String, + cwd: String, + sandbox_permissions: SandboxPermissions, + additional_permissions: Option, + justification: Option, + tty: bool, + }, + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: String, + additional_permissions: Option, + }, + ApplyPatch { + cwd: String, + files: Vec, + }, + NetworkAccess { + target: String, + host: String, + protocol: NetworkApprovalProtocol, + port: u16, + }, + McpToolCall { + server: String, + tool_name: String, + connector_id: Option, + connector_name: Option, + tool_title: Option, + }, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GuardianCommandSource { + Shell, + UnifiedExec, +} + +#[derive(Clone, Serialize)] +pub struct GuardianReviewEventParams { + pub thread_id: String, + pub turn_id: String, + pub review_id: String, + pub target_item_id: String, + pub retry_reason: Option, + pub approval_request_source: GuardianApprovalRequestSource, + pub reviewed_action: GuardianReviewedAction, + pub reviewed_action_truncated: bool, + pub decision: GuardianReviewDecision, + pub terminal_status: GuardianReviewTerminalStatus, + pub failure_reason: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, + pub rationale: Option, + pub guardian_thread_id: Option, + pub guardian_session_kind: Option, + pub guardian_model: Option, + pub guardian_reasoning_effort: Option, + pub had_prior_review_context: Option, + pub review_timeout_ms: u64, + pub tool_call_count: u64, + pub time_to_first_token_ms: Option, + pub completion_latency_ms: Option, + pub started_at: u64, + pub completed_at: Option, + pub input_tokens: Option, + pub cached_input_tokens: Option, + pub output_tokens: Option, + pub reasoning_output_tokens: Option, + pub total_tokens: Option, +} + +#[derive(Serialize)] +pub(crate) struct GuardianReviewEventPayload { + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + #[serde(flatten)] + pub(crate) guardian_review: GuardianReviewEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 16c482abd9..931ae01013 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -1,5 +1,6 @@ use crate::events::AppServerRpcTransport; use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianReviewEventParams; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::InitializeParams; @@ -154,6 +155,7 @@ pub(crate) enum AnalyticsFact { pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), + GuardianReview(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 0e7ef59256..9b4cc1e9bc 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -5,6 +5,17 @@ mod reducer; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; +pub use events::GuardianApprovalRequestSource; +pub use events::GuardianCommandSource; +pub use events::GuardianReviewDecision; +pub use events::GuardianReviewEventParams; +pub use events::GuardianReviewFailureReason; +pub use events::GuardianReviewOutcome; +pub use events::GuardianReviewRiskLevel; +pub use events::GuardianReviewSessionKind; +pub use events::GuardianReviewTerminalStatus; +pub use events::GuardianReviewUserAuthorization; +pub use events::GuardianReviewedAction; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; pub use facts::CompactionImplementation; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 7665b7bfd4..0ed2899762 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -6,6 +6,9 @@ use crate::events::CodexCompactionEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewEventPayload; +use crate::events::GuardianReviewEventRequest; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializationMode; @@ -120,6 +123,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Compaction(input) => { self.ingest_compaction(*input, out); } + CustomAnalyticsFact::GuardianReview(input) => { + self.ingest_guardian_review(*input, out); + } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; } @@ -174,6 +180,42 @@ impl AnalyticsReducer { )); } + fn ingest_guardian_review( + &mut self, + input: GuardianReviewEventParams, + out: &mut Vec, + ) { + let Some(connection_id) = self.thread_connections.get(&input.thread_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + review_id = %input.review_id, + "dropping guardian analytics event: missing thread connection metadata" + ); + return; + }; + let Some(connection_state) = self.connections.get(connection_id) else { + tracing::warn!( + thread_id = %input.thread_id, + turn_id = %input.turn_id, + review_id = %input.review_id, + connection_id, + "dropping guardian analytics event: missing connection metadata" + ); + return; + }; + out.push(TrackEventRequest::GuardianReview(Box::new( + GuardianReviewEventRequest { + event_type: "codex_guardian_review", + event_params: GuardianReviewEventPayload { + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + guardian_review: input, + }, + }, + ))); + } + async fn ingest_skill_invoked( &mut self, input: SkillInvokedInput, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 335958d49a..931b727c80 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1287,8 +1287,6 @@ mod tests { id: request.id, result: serde_json::to_value(GetAccountResponse { account: None, - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: false, }) .expect("response should serialize"), @@ -1397,8 +1395,6 @@ mod tests { id: request.id, result: serde_json::to_value(GetAccountResponse { account: None, - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: false, }) .expect("response should serialize"), @@ -1452,8 +1448,6 @@ mod tests { first_response, GetAccountResponse { account: None, - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: false, } ); @@ -1473,8 +1467,6 @@ mod tests { AccountUpdatedNotification { auth_mode: None, plan_type: None, - workspace_role: None, - is_workspace_owner: None, }, )) .expect("notification should serialize"), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3372758cd0..9e0a87b5e3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2562,17 +2562,6 @@ ], "type": "object" }, - "ThreadAddCreditsNudgeEmailParams": { - "properties": { - "threadId": { - "type": "string" - } - }, - "required": [ - "threadId" - ], - "type": "object" - }, "ThreadArchiveParams": { "properties": { "threadId": { @@ -3221,10 +3210,27 @@ "type": "null" } ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] } }, "type": "object" }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, "ThreadUnarchiveParams": { "properties": { "threadId": { @@ -3820,30 +3826,6 @@ "title": "Thread/shellCommandRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "thread/addCreditsNudgeEmail" - ], - "title": "Thread/addCreditsNudgeEmailRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Thread/addCreditsNudgeEmailRequest", - "type": "object" - }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f97a6df11f..6fe3513655 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -51,12 +51,6 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "planType": { "anyOf": [ { @@ -66,89 +60,10 @@ "type": "null" } ] - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "type": "object" }, - "AddCreditsNudgeEmailNotification": { - "properties": { - "result": { - "$ref": "#/definitions/AddCreditsNudgeEmailResult" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "result", - "threadId" - ], - "type": "object" - }, - "AddCreditsNudgeEmailResult": { - "oneOf": [ - { - "properties": { - "status": { - "enum": [ - "sent" - ], - "title": "SentAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "SentAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "status": { - "enum": [ - "cooldownActive" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "status": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "message", - "status" - ], - "type": "object" - } - ] - }, "AgentMessageDeltaNotification": { "properties": { "delta": { @@ -473,6 +388,13 @@ } ] }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1674,17 +1596,28 @@ "type": "object" }, "ItemGuardianApprovalReviewCompletedNotification": { - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -1694,15 +1627,16 @@ }, "required": [ "action", + "decisionSource", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], "type": "object" }, "ItemGuardianApprovalReviewStartedNotification": { - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" @@ -1710,9 +1644,17 @@ "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -1723,7 +1665,7 @@ "required": [ "action", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], @@ -2131,16 +2073,6 @@ "type": "null" } ] - }, - "spendControl": { - "anyOf": [ - { - "$ref": "#/definitions/SpendControlSnapshot" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -2340,17 +2272,6 @@ "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" }, - "SpendControlSnapshot": { - "properties": { - "reached": { - "type": "boolean" - } - }, - "required": [ - "reached" - ], - "type": "object" - }, "SubAgentSource": { "oneOf": [ { @@ -4135,14 +4056,6 @@ "samplePaths" ], "type": "object" - }, - "WorkspaceRole": { - "enum": [ - "account-owner", - "account-admin", - "standard-user" - ], - "type": "string" } }, "description": "Notification sent from the server to the client.", @@ -4770,26 +4683,6 @@ "title": "Account/rateLimits/updatedNotification", "type": "object" }, - { - "properties": { - "method": { - "enum": [ - "account/addCreditsNudgeEmail/completed" - ], - "title": "Account/addCreditsNudgeEmail/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AddCreditsNudgeEmailNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Account/addCreditsNudgeEmail/completedNotification", - "type": "object" - }, { "properties": { "method": { 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 665d030a70..b70dafa308 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 @@ -482,30 +482,6 @@ "title": "Thread/shellCommandRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "thread/addCreditsNudgeEmail" - ], - "title": "Thread/addCreditsNudgeEmailRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/ThreadAddCreditsNudgeEmailParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Thread/addCreditsNudgeEmailRequest", - "type": "object" - }, { "properties": { "id": { @@ -4058,26 +4034,6 @@ "title": "Account/rateLimits/updatedNotification", "type": "object" }, - { - "properties": { - "method": { - "enum": [ - "account/addCreditsNudgeEmail/completed" - ], - "title": "Account/addCreditsNudgeEmail/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/AddCreditsNudgeEmailNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Account/addCreditsNudgeEmail/completedNotification", - "type": "object" - }, { "properties": { "method": { @@ -4965,12 +4921,6 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "planType": { "anyOf": [ { @@ -4980,92 +4930,11 @@ "type": "null" } ] - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "title": "AccountUpdatedNotification", "type": "object" }, - "AddCreditsNudgeEmailNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "result": { - "$ref": "#/definitions/v2/AddCreditsNudgeEmailResult" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "result", - "threadId" - ], - "title": "AddCreditsNudgeEmailNotification", - "type": "object" - }, - "AddCreditsNudgeEmailResult": { - "oneOf": [ - { - "properties": { - "status": { - "enum": [ - "sent" - ], - "title": "SentAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "SentAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "status": { - "enum": [ - "cooldownActive" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "status": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "message", - "status" - ], - "type": "object" - } - ] - }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5667,6 +5536,13 @@ } ] }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -8142,24 +8018,8 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "requiresOpenaiAuth": { "type": "boolean" - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -8720,17 +8580,28 @@ }, "ItemGuardianApprovalReviewCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/v2/GuardianApprovalReviewAction" }, + "decisionSource": { + "$ref": "#/definitions/v2/AutoReviewDecisionSource" + }, "review": { "$ref": "#/definitions/v2/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -8740,8 +8611,9 @@ }, "required": [ "action", + "decisionSource", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], @@ -8750,7 +8622,7 @@ }, "ItemGuardianApprovalReviewStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/v2/GuardianApprovalReviewAction" @@ -8758,9 +8630,17 @@ "review": { "$ref": "#/definitions/v2/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -8771,7 +8651,7 @@ "required": [ "action", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], @@ -10544,16 +10424,6 @@ "type": "null" } ] - }, - "spendControl": { - "anyOf": [ - { - "$ref": "#/definitions/v2/SpendControlSnapshot" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -12377,17 +12247,6 @@ "title": "SkillsListResponse", "type": "object" }, - "SpendControlSnapshot": { - "properties": { - "reached": { - "type": "boolean" - } - }, - "required": [ - "reached" - ], - "type": "object" - }, "SubAgentSource": { "oneOf": [ { @@ -12679,24 +12538,6 @@ ], "type": "string" }, - "ThreadAddCreditsNudgeEmailParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "threadId": { - "type": "string" - } - }, - "required": [ - "threadId" - ], - "title": "ThreadAddCreditsNudgeEmailParams", - "type": "object" - }, - "ThreadAddCreditsNudgeEmailResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ThreadAddCreditsNudgeEmailResponse", - "type": "object" - }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14427,6 +14268,16 @@ "type": "null" } ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadStartSource" + }, + { + "type": "null" + } + ] } }, "title": "ThreadStartParams", @@ -14494,6 +14345,13 @@ "title": "ThreadStartResponse", "type": "object" }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, "ThreadStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15605,14 +15463,6 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, - "WorkspaceRole": { - "enum": [ - "account-owner", - "account-admin", - "standard-user" - ], - "type": "string" - }, "WriteStatus": { "enum": [ "ok", 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 6cf8364813..c4bcd774bd 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 @@ -100,12 +100,6 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "planType": { "anyOf": [ { @@ -115,92 +109,11 @@ "type": "null" } ] - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "title": "AccountUpdatedNotification", "type": "object" }, - "AddCreditsNudgeEmailNotification": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "result": { - "$ref": "#/definitions/AddCreditsNudgeEmailResult" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "result", - "threadId" - ], - "title": "AddCreditsNudgeEmailNotification", - "type": "object" - }, - "AddCreditsNudgeEmailResult": { - "oneOf": [ - { - "properties": { - "status": { - "enum": [ - "sent" - ], - "title": "SentAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "SentAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "status": { - "enum": [ - "cooldownActive" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "status": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "message", - "status" - ], - "type": "object" - } - ] - }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -802,6 +715,13 @@ } ] }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -1144,30 +1064,6 @@ "title": "Thread/shellCommandRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "thread/addCreditsNudgeEmail" - ], - "title": "Thread/addCreditsNudgeEmailRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Thread/addCreditsNudgeEmailRequest", - "type": "object" - }, { "properties": { "id": { @@ -4881,24 +4777,8 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "requiresOpenaiAuth": { "type": "boolean" - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "required": [ @@ -5503,17 +5383,28 @@ }, "ItemGuardianApprovalReviewCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -5523,8 +5414,9 @@ }, "required": [ "action", + "decisionSource", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], @@ -5533,7 +5425,7 @@ }, "ItemGuardianApprovalReviewStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" @@ -5541,9 +5433,17 @@ "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -5554,7 +5454,7 @@ "required": [ "action", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], @@ -7327,16 +7227,6 @@ "type": "null" } ] - }, - "spendControl": { - "anyOf": [ - { - "$ref": "#/definitions/SpendControlSnapshot" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -9346,26 +9236,6 @@ "title": "Account/rateLimits/updatedNotification", "type": "object" }, - { - "properties": { - "method": { - "enum": [ - "account/addCreditsNudgeEmail/completed" - ], - "title": "Account/addCreditsNudgeEmail/completedNotificationMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/AddCreditsNudgeEmailNotification" - } - }, - "required": [ - "method", - "params" - ], - "title": "Account/addCreditsNudgeEmail/completedNotification", - "type": "object" - }, { "properties": { "method": { @@ -10232,17 +10102,6 @@ "title": "SkillsListResponse", "type": "object" }, - "SpendControlSnapshot": { - "properties": { - "reached": { - "type": "boolean" - } - }, - "required": [ - "reached" - ], - "type": "object" - }, "SubAgentSource": { "oneOf": [ { @@ -10534,24 +10393,6 @@ ], "type": "string" }, - "ThreadAddCreditsNudgeEmailParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "threadId": { - "type": "string" - } - }, - "required": [ - "threadId" - ], - "title": "ThreadAddCreditsNudgeEmailParams", - "type": "object" - }, - "ThreadAddCreditsNudgeEmailResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ThreadAddCreditsNudgeEmailResponse", - "type": "object" - }, "ThreadArchiveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12282,6 +12123,16 @@ "type": "null" } ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] } }, "title": "ThreadStartParams", @@ -12349,6 +12200,13 @@ "title": "ThreadStartResponse", "type": "object" }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" + }, "ThreadStartedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13460,14 +13318,6 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, - "WorkspaceRole": { - "enum": [ - "account-owner", - "account-admin", - "standard-user" - ], - "type": "string" - }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index 8ec9642651..d1812f069f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -91,16 +91,6 @@ "type": "null" } ] - }, - "spendControl": { - "anyOf": [ - { - "$ref": "#/definitions/SpendControlSnapshot" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -130,17 +120,6 @@ "usedPercent" ], "type": "object" - }, - "SpendControlSnapshot": { - "properties": { - "reached": { - "type": "boolean" - } - }, - "required": [ - "reached" - ], - "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index ae37fc002d..f2cf7cb3ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -42,14 +42,6 @@ "unknown" ], "type": "string" - }, - "WorkspaceRole": { - "enum": [ - "account-owner", - "account-admin", - "standard-user" - ], - "type": "string" } }, "properties": { @@ -63,12 +55,6 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "planType": { "anyOf": [ { @@ -78,16 +64,6 @@ "type": "null" } ] - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "title": "AccountUpdatedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json deleted file mode 100644 index 19fee5c5e2..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "AddCreditsNudgeEmailResult": { - "oneOf": [ - { - "properties": { - "status": { - "enum": [ - "sent" - ], - "title": "SentAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "SentAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "status": { - "enum": [ - "cooldownActive" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResultStatus", - "type": "string" - } - }, - "required": [ - "status" - ], - "title": "CooldownActiveAddCreditsNudgeEmailResult", - "type": "object" - }, - { - "properties": { - "message": { - "type": "string" - }, - "status": { - "enum": [ - "failed" - ], - "type": "string" - } - }, - "required": [ - "message", - "status" - ], - "type": "object" - } - ] - } - }, - "properties": { - "result": { - "$ref": "#/definitions/AddCreditsNudgeEmailResult" - }, - "threadId": { - "type": "string" - } - }, - "required": [ - "result", - "threadId" - ], - "title": "AddCreditsNudgeEmailNotification", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index b89007e933..23f7d3cfdc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -91,16 +91,6 @@ "type": "null" } ] - }, - "spendControl": { - "anyOf": [ - { - "$ref": "#/definitions/SpendControlSnapshot" - }, - { - "type": "null" - } - ] } }, "type": "object" @@ -130,17 +120,6 @@ "usedPercent" ], "type": "object" - }, - "SpendControlSnapshot": { - "properties": { - "reached": { - "type": "boolean" - } - }, - "required": [ - "reached" - ], - "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index a72db5b29c..acaa779184 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -60,14 +60,6 @@ "unknown" ], "type": "string" - }, - "WorkspaceRole": { - "enum": [ - "account-owner", - "account-admin", - "standard-user" - ], - "type": "string" } }, "properties": { @@ -81,24 +73,8 @@ } ] }, - "isWorkspaceOwner": { - "type": [ - "boolean", - "null" - ] - }, "requiresOpenaiAuth": { "type": "boolean" - }, - "workspaceRole": { - "anyOf": [ - { - "$ref": "#/definitions/WorkspaceRole" - }, - { - "type": "null" - } - ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 1f706e5453..f8b9199bbe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -1,6 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal guardian approval review decision.", + "enum": [ + "agent" + ], + "type": "string" + }, "GuardianApprovalReview": { "description": "[UNSTABLE] Temporary guardian approval review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", "properties": { @@ -256,17 +263,28 @@ "type": "string" } }, - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -276,8 +294,9 @@ }, "required": [ "action", + "decisionSource", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index cd9adf32f5..9f6c35b2a7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -256,7 +256,7 @@ "type": "string" } }, - "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.\n\nTODO(ccunningham): Attach guardian review state to the reviewed tool item's lifecycle instead of sending separate standalone review notifications so the app-server API can persist and replay review state via `thread/read`.", + "description": "[UNSTABLE] Temporary notification payload for guardian automatic approval review. This shape is expected to change soon.", "properties": { "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" @@ -264,9 +264,17 @@ "review": { "$ref": "#/definitions/GuardianApprovalReview" }, - "targetItemId": { + "reviewId": { + "description": "Stable identifier for this review.", "type": "string" }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn 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\n\nA 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.", + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -277,7 +285,7 @@ "required": [ "action", "review", - "targetItemId", + "reviewId", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json deleted file mode 100644 index 75db04f4d8..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "threadId": { - "type": "string" - } - }, - "required": [ - "threadId" - ], - "title": "ThreadAddCreditsNudgeEmailParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json deleted file mode 100644 index 1def4b406e..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ThreadAddCreditsNudgeEmailResponse", - "type": "object" -} \ No newline at end of file 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 b4391c7ab5..21f4d7ef74 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -101,6 +101,13 @@ "flex" ], "type": "string" + }, + "ThreadStartSource": { + "enum": [ + "startup", + "clear" + ], + "type": "string" } }, "properties": { @@ -210,6 +217,16 @@ "type": "null" } ] + }, + "sessionStartSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadStartSource" + }, + { + "type": "null" + } + ] } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 95432a9098..d12599d7a9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,7 +43,6 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; -import type { ThreadAddCreditsNudgeEmailParams } from "./v2/ThreadAddCreditsNudgeEmailParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; @@ -66,4 +65,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/addCreditsNudgeEmail", id: RequestId, params: ThreadAddCreditsNudgeEmailParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index e10512cb94..a985914134 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -6,7 +6,6 @@ import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearc import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification"; import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification"; import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; -import type { AddCreditsNudgeEmailNotification } from "./v2/AddCreditsNudgeEmailNotification"; import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification"; import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification"; import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification"; @@ -59,4 +58,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "account/addCreditsNudgeEmail/completed", "params": AddCreditsNudgeEmailNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts index eee18bc9c4..84bf626e0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts @@ -3,6 +3,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AuthMode } from "../AuthMode"; import type { PlanType } from "../PlanType"; -import type { WorkspaceRole } from "./WorkspaceRole"; -export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, }; +export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts deleted file mode 100644 index 855397899d..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult"; - -export type AddCreditsNudgeEmailNotification = { threadId: string, result: AddCreditsNudgeEmailResult, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts deleted file mode 100644 index 3aed1d1a87..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AddCreditsNudgeEmailResult = { "status": "sent" } | { "status": "cooldownActive" } | { "status": "failed", message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts similarity index 52% rename from codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts index 626750e688..aa776c8ca5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AutoReviewDecisionSource.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadAddCreditsNudgeEmailResponse = Record; +/** + * [UNSTABLE] Source that produced a terminal guardian approval review decision. + */ +export type AutoReviewDecisionSource = "agent"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts index b9d5d89dac..83da4f4e5e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts @@ -2,6 +2,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Account } from "./Account"; -import type { WorkspaceRole } from "./WorkspaceRole"; -export type GetAccountResponse = { account: Account | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, requiresOpenaiAuth: boolean, }; +export type GetAccountResponse = { account: Account | null, requiresOpenaiAuth: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts index a7ae038b36..a345e3d655 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts @@ -1,15 +1,30 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; import type { GuardianApprovalReview } from "./GuardianApprovalReview"; import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction"; /** * [UNSTABLE] Temporary notification payload for guardian automatic approval * review. This shape is expected to change soon. - * - * TODO(ccunningham): Attach guardian review state to the reviewed tool item's - * lifecycle instead of sending separate standalone review notifications so the - * app-server API can persist and replay review state via `thread/read`. */ -export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; +export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, +/** + * Stable identifier for this review. + */ +reviewId: 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. + */ +targetItemId: string | null, decisionSource: AutoReviewDecisionSource, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts index 706b8d55af..f917aa3ee7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts @@ -7,9 +7,23 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio /** * [UNSTABLE] Temporary notification payload for guardian automatic approval * review. This shape is expected to change soon. - * - * TODO(ccunningham): Attach guardian review state to the reviewed tool item's - * lifecycle instead of sending separate standalone review notifications so the - * app-server API can persist and replay review state via `thread/read`. */ -export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, targetItemId: string, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; +export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, +/** + * Stable identifier for this review. + */ +reviewId: 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. + */ +targetItemId: string | null, review: GuardianApprovalReview, action: GuardianApprovalReviewAction, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index 4aea9d317b..0c2ebe1893 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -4,6 +4,5 @@ import type { PlanType } from "../PlanType"; import type { CreditsSnapshot } from "./CreditsSnapshot"; import type { RateLimitWindow } from "./RateLimitWindow"; -import type { SpendControlSnapshot } from "./SpendControlSnapshot"; -export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, spendControl: SpendControlSnapshot | null, planType: PlanType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts deleted file mode 100644 index df4fc767be..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ThreadAddCreditsNudgeEmailParams = { threadId: string, }; 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 61f501ad60..904487e81c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -7,12 +7,13 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +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, /** +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, /** * If true, opt into emitting raw Responses API items on the event stream. * This is for internal use only (e.g. Codex Cloud). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts similarity index 72% rename from codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts index ba812459db..ea1b839c6b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartSource.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SpendControlSnapshot = { reached: boolean, }; +export type ThreadStartSource = "startup" | "clear"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts deleted file mode 100644 index 17846b49b3..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type WorkspaceRole = "account-owner" | "account-admin" | "standard-user"; 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 d8dd15c97c..7803355794 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,8 +4,6 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; -export type { AddCreditsNudgeEmailNotification } from "./AddCreditsNudgeEmailNotification"; -export type { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; @@ -26,6 +24,7 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; +export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; export type { CancelLoginAccountResponse } from "./CancelLoginAccountResponse"; @@ -267,15 +266,12 @@ export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; -export type { SpendControlSnapshot } from "./SpendControlSnapshot"; export type { TerminalInteractionNotification } from "./TerminalInteractionNotification"; export type { TextElement } from "./TextElement"; export type { TextPosition } from "./TextPosition"; export type { TextRange } from "./TextRange"; export type { Thread } from "./Thread"; export type { ThreadActiveFlag } from "./ThreadActiveFlag"; -export type { ThreadAddCreditsNudgeEmailParams } from "./ThreadAddCreditsNudgeEmailParams"; -export type { ThreadAddCreditsNudgeEmailResponse } from "./ThreadAddCreditsNudgeEmailResponse"; export type { ThreadArchiveParams } from "./ThreadArchiveParams"; export type { ThreadArchiveResponse } from "./ThreadArchiveResponse"; export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; @@ -316,6 +312,7 @@ export type { ThreadSortKey } from "./ThreadSortKey"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; export type { ThreadStartResponse } from "./ThreadStartResponse"; +export type { ThreadStartSource } from "./ThreadStartSource"; export type { ThreadStartedNotification } from "./ThreadStartedNotification"; export type { ThreadStatus } from "./ThreadStatus"; export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification"; @@ -356,5 +353,4 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; -export type { WorkspaceRole } from "./WorkspaceRole"; export type { WriteStatus } from "./WriteStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 14eb1f5f39..731288272b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -296,10 +296,6 @@ client_request_definitions! { params: v2::ThreadShellCommandParams, response: v2::ThreadShellCommandResponse, }, - ThreadAddCreditsNudgeEmail => "thread/addCreditsNudgeEmail" { - params: v2::ThreadAddCreditsNudgeEmailParams, - response: v2::ThreadAddCreditsNudgeEmailResponse, - }, #[experimental("thread/backgroundTerminals/clean")] ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" { params: v2::ThreadBackgroundTerminalsCleanParams, @@ -995,7 +991,6 @@ server_notification_definitions! { McpServerStatusUpdated => "mcpServer/startupStatus/updated" (v2::McpServerStatusUpdatedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), - AddCreditsNudgeEmailCompleted => "account/addCreditsNudgeEmail/completed" (v2::AddCreditsNudgeEmailNotification), AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification), FsChanged => "fs/changed" (v2::FsChangedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 9853f69f40..375e5605d4 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -11,6 +11,7 @@ //! - The projection is presentation-specific. Core protocol events stay generic, while the //! app-server protocol decides how to surface those events as `ThreadItem`s for clients. use crate::protocol::common::ServerNotification; +use crate::protocol::v2::AutoReviewDecisionSource; use crate::protocol::v2::CommandAction; use crate::protocol::v2::CommandExecutionSource; use crate::protocol::v2::CommandExecutionStatus; @@ -142,12 +143,13 @@ pub fn build_item_from_guardian_event( ) -> Option { match &assessment.action { GuardianAssessmentAction::Command { command, cwd, .. } => { + let id = assessment.target_item_id.as_ref()?; let command = command.clone(); let command_actions = vec![CommandAction::Unknown { command: command.clone(), }]; Some(ThreadItem::CommandExecution { - id: assessment.id.clone(), + id: id.clone(), command, cwd: cwd.clone(), process_id: None, @@ -162,6 +164,7 @@ pub fn build_item_from_guardian_event( GuardianAssessmentAction::Execve { program, argv, cwd, .. } => { + let id = assessment.target_item_id.as_ref()?; let argv = if argv.is_empty() { vec![program.clone()] } else { @@ -179,7 +182,7 @@ pub fn build_item_from_guardian_event( parsed_cmd.into_iter().map(CommandAction::from).collect() }; Some(ThreadItem::CommandExecution { - id: assessment.id.clone(), + id: id.clone(), command, cwd: cwd.clone(), process_id: None, @@ -202,9 +205,6 @@ pub fn guardian_auto_approval_review_notification( event_turn_id: &str, assessment: &GuardianAssessmentEvent, ) -> ServerNotification { - // TODO(ccunningham): Attach guardian review state to the reviewed tool - // item's lifecycle instead of sending standalone review notifications so - // the app-server API can persist and replay review state via `thread/read`. let turn_id = if assessment.turn_id.is_empty() { event_turn_id.to_string() } else { @@ -236,7 +236,8 @@ pub fn guardian_auto_approval_review_notification( ItemGuardianApprovalReviewStartedNotification { thread_id: conversation_id.to_string(), turn_id, - target_item_id: assessment.id.clone(), + review_id: assessment.id.clone(), + target_item_id: assessment.target_item_id.clone(), review, action, }, @@ -249,7 +250,12 @@ pub fn guardian_auto_approval_review_notification( ItemGuardianApprovalReviewCompletedNotification { thread_id: conversation_id.to_string(), turn_id, - target_item_id: assessment.id.clone(), + review_id: assessment.id.clone(), + target_item_id: assessment.target_item_id.clone(), + decision_source: assessment + .decision_source + .map(AutoReviewDecisionSource::from) + .unwrap_or(AutoReviewDecisionSource::Agent), review, action, }, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index b005657968..a99fe6d4f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2088,12 +2088,14 @@ mod tests { local_images: Vec::new(), }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-exec".into(), + id: "review-guardian-exec".into(), + target_item_id: Some("guardian-exec".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: serde_json::from_value(serde_json::json!({ "type": "command", "source": "shell", @@ -2103,12 +2105,16 @@ mod tests { .expect("guardian action"), }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-exec".into(), + id: "review-guardian-exec".into(), + target_item_id: Some("guardian-exec".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), rationale: Some("Would delete user data.".into()), + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), action: serde_json::from_value(serde_json::json!({ "type": "command", "source": "shell", @@ -2161,12 +2167,14 @@ mod tests { local_images: Vec::new(), }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: "guardian-execve".into(), + id: "review-guardian-execve".into(), + target_item_id: Some("guardian-execve".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: serde_json::from_value(serde_json::json!({ "type": "execve", "source": "shell", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 285a1247f6..02996df720 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -9,6 +9,7 @@ use codex_protocol::account::PlanType; 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; @@ -82,7 +83,6 @@ 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::SpendControlSnapshot as CoreSpendControlSnapshot; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; @@ -413,6 +413,14 @@ v2_enum_from_core!( } ); +#[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/")] @@ -1739,27 +1747,11 @@ pub struct GetAccountParams { pub refresh_token: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum WorkspaceRole { - #[serde(rename = "account-owner")] - #[ts(rename = "account-owner")] - AccountOwner, - #[serde(rename = "account-admin")] - #[ts(rename = "account-admin")] - AccountAdmin, - #[serde(rename = "standard-user")] - #[ts(rename = "standard-user")] - StandardUser, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GetAccountResponse { pub account: Option, - pub workspace_role: Option, - pub is_workspace_owner: Option, pub requires_openai_auth: bool, } @@ -2633,6 +2625,8 @@ pub struct ThreadStartParams { pub personality: Option, #[ts(optional = nullable)] pub ephemeral: Option, + #[ts(optional = nullable)] + pub session_start_source: Option, #[experimental("thread/start.dynamicTools")] #[ts(optional = nullable)] pub dynamic_tools: Option>, @@ -3036,18 +3030,6 @@ pub struct ThreadShellCommandParams { #[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 ThreadAddCreditsNudgeEmailParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadAddCreditsNudgeEmailResponse {} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3685,8 +3667,6 @@ pub struct Thread { pub struct AccountUpdatedNotification { pub auth_mode: Option, pub plan_type: Option, - pub workspace_role: Option, - pub is_workspace_owner: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4555,6 +4535,22 @@ pub enum GuardianApprovalReviewStatus { 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 guardian approval 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/")] @@ -5311,14 +5307,23 @@ pub struct ItemStartedNotification { #[ts(export_to = "v2/")] /// [UNSTABLE] Temporary notification payload for guardian automatic approval /// review. This shape is expected to change soon. -/// -/// TODO(ccunningham): Attach guardian review state to the reviewed tool item's -/// lifecycle instead of sending separate standalone review notifications so the -/// app-server API can persist and replay review state via `thread/read`. pub struct ItemGuardianApprovalReviewStartedNotification { pub thread_id: String, pub turn_id: String, - pub target_item_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, } @@ -5328,14 +5333,24 @@ pub struct ItemGuardianApprovalReviewStartedNotification { #[ts(export_to = "v2/")] /// [UNSTABLE] Temporary notification payload for guardian automatic approval /// review. This shape is expected to change soon. -/// -/// TODO(ccunningham): Attach guardian review state to the reviewed tool item's -/// lifecycle instead of sending separate standalone review notifications so the -/// app-server API can persist and replay review state via `thread/read`. pub struct ItemGuardianApprovalReviewCompletedNotification { pub thread_id: String, pub turn_id: String, - pub target_item_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, } @@ -6258,24 +6273,6 @@ pub struct AccountRateLimitsUpdatedNotification { pub rate_limits: RateLimitSnapshot, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AddCreditsNudgeEmailNotification { - pub thread_id: String, - pub result: AddCreditsNudgeEmailResult, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "status", rename_all = "camelCase")] -#[ts(tag = "status", rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum AddCreditsNudgeEmailResult { - Sent, - CooldownActive, - Failed { message: String }, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6285,7 +6282,6 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, - pub spend_control: Option, pub plan_type: Option, } @@ -6297,7 +6293,6 @@ impl From for RateLimitSnapshot { primary: value.primary.map(RateLimitWindow::from), secondary: value.secondary.map(RateLimitWindow::from), credits: value.credits.map(CreditsSnapshot::from), - spend_control: value.spend_control.map(SpendControlSnapshot::from), plan_type: value.plan_type, } } @@ -6343,21 +6338,6 @@ impl From for CreditsSnapshot { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SpendControlSnapshot { - pub reached: bool, -} - -impl From for SpendControlSnapshot { - fn from(value: CoreSpendControlSnapshot) -> Self { - Self { - reached: value.reached, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index eeaf9ef23e..19fbb0702c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -133,7 +133,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -147,7 +147,6 @@ Example with notification opt-out: - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. -- `thread/addCreditsNudgeEmail` — ask the backend to notify the workspace owner that the thread hit a usage limit; returns `{}` immediately and emits `account/addCreditsNudgeEmail/completed` with the result. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". @@ -213,6 +212,7 @@ Start a fresh thread when you need a new Codex conversation. "sandbox": "workspaceWrite", "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) + "sessionStartSource": "startup", // optional: "startup" (default) or "clear" // Experimental: requires opt-in "dynamicTools": [ { @@ -457,15 +457,6 @@ If the thread does not already have an active turn, the server starts a standalo { "id": 26, "result": {} } ``` -### Example: Notify Workspace Owner About Usage Limit - -Use `thread/addCreditsNudgeEmail` when a usage-limit prompt needs to notify the thread's workspace owner. The request returns immediately with `{}`. The final delivery result is emitted separately as `account/addCreditsNudgeEmail/completed` for the same `threadId`. - -```json -{ "method": "thread/addCreditsNudgeEmail", "id": 27, "params": { "threadId": "thr_b" } } -{ "id": 27, "result": {} } -``` - ### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: @@ -1366,19 +1357,19 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i ### Authentication modes -Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, the current `workspaceRole` when live account metadata can be fetched, and the derived `isWorkspaceOwner` flag. The same cached account state can be read from `account/read`. +Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`. - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. - **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. ### API Overview -- `account/read` — fetch cached current account info; optionally refresh tokens. ChatGPT workspace role is refreshed in the background and delivered by `account/updated` so this request does not wait for live account metadata. +- `account/read` — fetch current account info; optionally refresh tokens. - `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType`, `workspaceRole`, and `isWorkspaceOwner`. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. @@ -1395,19 +1386,16 @@ Request: Response examples: ```json -{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models) -{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) -{ "id": 1, "result": { "account": { "type": "apiKey" }, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } } -{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "workspaceRole": null, "isWorkspaceOwner": true, "requiresOpenaiAuth": true } } -{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "pro", "workspaceRole": "account-admin", "isWorkspaceOwner": true } } // emitted when live workspace metadata arrives +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models) +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) +{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } ``` Field notes: - `refreshToken` (bool): set `true` to force a token refresh. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. -- `workspaceRole` is a nullable live account role from ChatGPT account metadata: `account-owner`, `account-admin`, or `standard-user`. It is normally delivered asynchronously in `account/updated`; clients should treat `null` from `account/read` as "not known yet". -- `isWorkspaceOwner` is a nullable convenience flag. When `workspaceRole` is available, owners and admins map to `true` and standard users map to `false`; otherwise the server may fall back to the managed-account token's workspace-owner claim. ### 2) Log in with an API key @@ -1426,7 +1414,7 @@ Field notes: 3. Notifications: ```json { "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "apikey", "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } } + { "method": "account/updated", "params": { "authMode": "apikey", "planType": null } } ``` ### 3) Log in with ChatGPT (browser flow) @@ -1440,7 +1428,7 @@ Field notes: 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } ``` ### 4) Log in with ChatGPT (device code flow) @@ -1454,7 +1442,7 @@ Field notes: 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } ``` ### 5) Cancel a ChatGPT login @@ -1469,7 +1457,7 @@ Field notes: ```json { "method": "account/logout", "id": 6 } { "id": 6, "result": {} } -{ "method": "account/updated", "params": { "authMode": null, "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } } +{ "method": "account/updated", "params": { "authMode": null, "planType": null } } ``` ### 7) Rate limits (ChatGPT) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ce71e8974d..d8990483b7 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -13,8 +13,6 @@ use crate::thread_state::resolve_server_request_on_thread_listener; use crate::thread_status::ThreadWatchActiveGuard; use crate::thread_status::ThreadWatchManager; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; -use codex_app_server_protocol::AddCreditsNudgeEmailNotification; -use codex_app_server_protocol::AddCreditsNudgeEmailResult; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::ApplyPatchApprovalParams; @@ -119,7 +117,6 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -226,26 +223,6 @@ pub(crate) async fn apply_bespoke_event_handling( ) .await; } - EventMsg::AddCreditsNudgeEmailResponse(event) => { - if let ApiVersion::V2 = api_version { - let result = match event.result { - Ok(CoreAddCreditsNudgeEmailStatus::Sent) => AddCreditsNudgeEmailResult::Sent, - Ok(CoreAddCreditsNudgeEmailStatus::CooldownActive) => { - AddCreditsNudgeEmailResult::CooldownActive - } - Err(message) => AddCreditsNudgeEmailResult::Failed { message }, - }; - let notification = AddCreditsNudgeEmailNotification { - thread_id: conversation_id.to_string(), - result, - }; - outgoing - .send_server_notification(ServerNotification::AddCreditsNudgeEmailCompleted( - notification, - )) - .await; - } - } EventMsg::SkillsUpdateAvailable => { if let ApiVersion::V2 = api_version { outgoing @@ -291,15 +268,19 @@ pub(crate) async fn apply_bespoke_event_handling( CommandExecutionStatus::InProgress, ) { Some(ThreadItem::CommandExecution { + id, command, cwd, command_actions, .. - }) => Some(CommandExecutionCompletionItem { - command, - cwd, - command_actions, - }), + }) => Some(( + id, + CommandExecutionCompletionItem { + command, + cwd, + command_actions, + }, + )), Some(_) | None => None, }; let assessment_turn_id = if assessment.turn_id.is_empty() { @@ -309,12 +290,13 @@ pub(crate) async fn apply_bespoke_event_handling( }; if assessment.status == codex_protocol::protocol::GuardianAssessmentStatus::InProgress - && let Some(completion_item) = pending_command_execution.as_ref() + && let Some((target_item_id, completion_item)) = + pending_command_execution.as_ref() { start_command_execution_item( &conversation_id, assessment_turn_id.clone(), - assessment.id.clone(), + target_item_id.clone(), completion_item.command.clone(), completion_item.cwd.clone(), completion_item.command_actions.clone(), @@ -334,12 +316,12 @@ pub(crate) async fn apply_bespoke_event_handling( assessment.status, codex_protocol::protocol::GuardianAssessmentStatus::Denied | codex_protocol::protocol::GuardianAssessmentStatus::Aborted - ) && let Some(completion_item) = pending_command_execution + ) && let Some((target_item_id, completion_item)) = pending_command_execution { complete_command_execution_item( &conversation_id, assessment_turn_id, - assessment.id.clone(), + target_item_id, completion_item.command, completion_item.cwd, /*process_id*/ None, @@ -2919,6 +2901,7 @@ mod tests { use anyhow::Result; use anyhow::anyhow; use anyhow::bail; + use codex_app_server_protocol::AutoReviewDecisionSource; use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; @@ -3020,12 +3003,18 @@ mod tests { GuardianAssessmentStatus::Aborted => (None, None, None), }; GuardianAssessmentEvent { - id: id.to_string(), + id: format!("review-{id}"), + target_item_id: Some(id.to_string()), turn_id: turn_id.to_string(), status, risk_level, user_authorization, rationale, + decision_source: if matches!(status, GuardianAssessmentStatus::InProgress) { + None + } else { + Some(codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent) + }, action: serde_json::from_value(json!({ "type": "command", "source": "shell", @@ -3080,12 +3069,14 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { - id: "item-1".to_string(), + id: "review-1".to_string(), + target_item_id: Some("item-1".to_string()), turn_id: String::new(), status: codex_protocol::protocol::GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: action.clone(), }, ); @@ -3094,7 +3085,8 @@ mod tests { ServerNotification::ItemGuardianApprovalReviewStarted(payload) => { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-event"); - assert_eq!(payload.target_item_id, "item-1"); + assert_eq!(payload.review_id, "review-1"); + assert_eq!(payload.target_item_id.as_deref(), Some("item-1")); assert_eq!( payload.review.status, GuardianApprovalReviewStatus::InProgress @@ -3120,12 +3112,16 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { - id: "item-2".to_string(), + id: "review-2".to_string(), + target_item_id: Some("item-2".to_string()), turn_id: "turn-from-assessment".to_string(), status: codex_protocol::protocol::GuardianAssessmentStatus::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), rationale: Some("too risky".to_string()), + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), action: action.clone(), }, ); @@ -3134,7 +3130,9 @@ mod tests { ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-assessment"); - assert_eq!(payload.target_item_id, "item-2"); + assert_eq!(payload.review_id, "review-2"); + assert_eq!(payload.target_item_id.as_deref(), Some("item-2")); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); assert_eq!( payload.review.risk_level, @@ -3164,12 +3162,16 @@ mod tests { &conversation_id, "turn-from-event", &GuardianAssessmentEvent { - id: "item-3".to_string(), + id: "review-3".to_string(), + target_item_id: None, turn_id: "turn-from-assessment".to_string(), status: codex_protocol::protocol::GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, rationale: None, + decision_source: Some( + codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + ), action: action.clone(), }, ); @@ -3178,7 +3180,9 @@ mod tests { ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-assessment"); - assert_eq!(payload.target_item_id, "item-3"); + assert_eq!(payload.review_id, "review-3"); + assert_eq!(payload.target_item_id, None); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Aborted); assert_eq!(payload.review.risk_level, None); assert_eq!(payload.review.user_authorization, None); @@ -3395,7 +3399,11 @@ mod tests { OutgoingMessage::AppServerNotification( ServerNotification::ItemGuardianApprovalReviewStarted(payload), ) => { - assert_eq!(payload.target_item_id, "cmd-guardian-approved"); + assert_eq!(payload.review_id, "review-cmd-guardian-approved"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-approved") + ); assert_eq!( payload.review.status, GuardianApprovalReviewStatus::InProgress @@ -3416,7 +3424,12 @@ mod tests { OutgoingMessage::AppServerNotification( ServerNotification::ItemGuardianApprovalReviewCompleted(payload), ) => { - assert_eq!(payload.target_item_id, "cmd-guardian-approved"); + assert_eq!(payload.review_id, "review-cmd-guardian-approved"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-approved") + ); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); assert_eq!( payload.review.status, GuardianApprovalReviewStatus::Approved @@ -3453,7 +3466,11 @@ mod tests { OutgoingMessage::AppServerNotification( ServerNotification::ItemGuardianApprovalReviewStarted(payload), ) => { - assert_eq!(payload.target_item_id, "cmd-guardian-denied"); + assert_eq!(payload.review_id, "review-cmd-guardian-denied"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-denied") + ); assert_eq!( payload.review.status, GuardianApprovalReviewStatus::InProgress @@ -3474,7 +3491,12 @@ mod tests { OutgoingMessage::AppServerNotification( ServerNotification::ItemGuardianApprovalReviewCompleted(payload), ) => { - assert_eq!(payload.target_item_id, "cmd-guardian-denied"); + assert_eq!(payload.review_id, "review-cmd-guardian-denied"); + assert_eq!( + payload.target_item_id.as_deref(), + Some("cmd-guardian-denied") + ); + assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); assert_eq!(payload.review.status, GuardianApprovalReviewStatus::Denied); } other => bail!("unexpected message: {other:?}"), @@ -3491,6 +3513,30 @@ mod tests { other => bail!("unexpected message: {other:?}"), } + let mut missing_target = guardian_command_assessment( + "cmd-guardian-missing-target", + "turn-guardian-missing-target", + GuardianAssessmentStatus::InProgress, + ); + missing_target.target_item_id = None; + guardian_context + .apply_guardian_assessment_event(missing_target) + .await; + let eighth = recv_broadcast_message(&mut rx).await?; + match eighth { + OutgoingMessage::AppServerNotification( + ServerNotification::ItemGuardianApprovalReviewStarted(payload), + ) => { + assert_eq!(payload.review_id, "review-cmd-guardian-missing-target"); + assert_eq!(payload.target_item_id, None); + assert_eq!( + payload.review.status, + GuardianApprovalReviewStatus::InProgress + ); + } + other => bail!("unexpected message: {other:?}"), + } + assert!(rx.try_recv().is_err(), "no extra messages expected"); conversation.shutdown_and_wait().await?; Ok(()) @@ -3993,7 +4039,6 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), - spend_control: None, plan_type: None, }; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 795723f9d3..e0509907b9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -116,8 +116,6 @@ use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadArchivedNotification; @@ -186,7 +184,6 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; -use codex_app_server_protocol::WorkspaceRole; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::Client as BackendClient; @@ -203,7 +200,6 @@ use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; use codex_core::ThreadSortKey as CoreThreadSortKey; -use codex_core::WorkspaceRole as CoreWorkspaceRole; use codex_core::append_thread_name; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -490,96 +486,6 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) log_db: Option, } -async fn resolve_workspace_role_and_owner_for_auth( - chatgpt_base_url: &str, - auth: Option<&CodexAuth>, -) -> (Option, Option) { - let ownership = - codex_core::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await; - ( - ownership.workspace_role.map(workspace_role_to_v2), - ownership.is_workspace_owner, - ) -} - -fn workspace_role_to_v2(role: CoreWorkspaceRole) -> WorkspaceRole { - match role { - CoreWorkspaceRole::AccountOwner => WorkspaceRole::AccountOwner, - CoreWorkspaceRole::AccountAdmin => WorkspaceRole::AccountAdmin, - CoreWorkspaceRole::StandardUser => WorkspaceRole::StandardUser, - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct AuthIdentity { - auth_mode: AuthMode, - account_id: Option, - account_email: Option, - chatgpt_user_id: Option, -} - -fn auth_identity(auth: &CodexAuth) -> AuthIdentity { - AuthIdentity { - auth_mode: auth.api_auth_mode(), - account_id: auth.get_account_id(), - account_email: auth.get_account_email(), - chatgpt_user_id: auth.get_chatgpt_user_id(), - } -} - -fn cached_workspace_role_and_owner_for_auth( - auth: Option<&CodexAuth>, -) -> (Option, Option) { - (None, auth.and_then(CodexAuth::is_workspace_owner)) -} - -fn account_updated_notification_for_auth( - auth: Option<&CodexAuth>, - workspace_role: Option, - is_workspace_owner: Option, -) -> AccountUpdatedNotification { - AccountUpdatedNotification { - auth_mode: auth.map(CodexAuth::api_auth_mode), - plan_type: auth.and_then(CodexAuth::account_plan_type), - workspace_role, - is_workspace_owner, - } -} - -fn spawn_live_workspace_role_update_for_auth( - outgoing: Arc, - auth_manager: Arc, - chatgpt_base_url: String, - auth: Option, -) { - let Some(auth) = auth.filter(CodexAuth::is_chatgpt_auth) else { - return; - }; - let expected_identity = auth_identity(&auth); - - tokio::spawn(async move { - let (workspace_role, is_workspace_owner) = - resolve_workspace_role_and_owner_for_auth(&chatgpt_base_url, Some(&auth)).await; - let Some(workspace_role) = workspace_role else { - return; - }; - - let current_auth = auth_manager.auth_cached(); - if current_auth.as_ref().map(auth_identity) != Some(expected_identity) { - return; - } - - let payload = account_updated_notification_for_auth( - current_auth.as_ref(), - Some(workspace_role), - is_workspace_owner, - ); - outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload)) - .await; - }); -} - impl CodexMessageProcessor { pub(crate) fn handle_config_mutation(&self) { self.clear_plugin_related_caches(); @@ -592,18 +498,10 @@ impl CodexMessageProcessor { fn current_account_updated_notification(&self) -> AccountUpdatedNotification { let auth = self.auth_manager.auth_cached(); - let (workspace_role, is_workspace_owner) = - cached_workspace_role_and_owner_for_auth(auth.as_ref()); - account_updated_notification_for_auth(auth.as_ref(), workspace_role, is_workspace_owner) - } - - fn spawn_live_workspace_role_update(&self, auth: Option) { - spawn_live_workspace_role_update_for_auth( - self.outgoing.clone(), - self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), - auth, - ); + AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + } } async fn load_thread( @@ -887,10 +785,6 @@ impl CodexMessageProcessor { self.thread_shell_command(to_connection_request_id(request_id), params) .await; } - ClientRequest::ThreadAddCreditsNudgeEmail { request_id, params } => { - self.thread_add_credits_nudge_email(to_connection_request_id(request_id), params) - .await; - } ClientRequest::SkillsList { request_id, params } => { self.skills_list(to_connection_request_id(request_id), params) .await; @@ -1210,7 +1104,6 @@ impl CodexMessageProcessor { self.current_account_updated_notification(), )) .await; - self.spawn_live_workspace_role_update(self.auth_manager.auth_cached()); } Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -1335,7 +1228,7 @@ impl CodexMessageProcessor { replace_cloud_requirements_loader( cloud_requirements.as_ref(), auth_manager.clone(), - chatgpt_base_url.clone(), + chatgpt_base_url, codex_home, ); sync_default_client_residency_requirement( @@ -1344,27 +1237,17 @@ impl CodexMessageProcessor { ) .await; - // Notify clients with the actual current auth mode immediately; the - // live workspace role is fetched below without delaying this update. + // Notify clients with the actual current auth mode. let auth = auth_manager.auth_cached(); - let (workspace_role, is_workspace_owner) = - cached_workspace_role_and_owner_for_auth(auth.as_ref()); - let payload_v2 = account_updated_notification_for_auth( - auth.as_ref(), - workspace_role, - is_workspace_owner, - ); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; outgoing_clone .send_server_notification(ServerNotification::AccountUpdated( payload_v2, )) .await; - spawn_live_workspace_role_update_for_auth( - outgoing_clone.clone(), - auth_manager.clone(), - chatgpt_base_url.clone(), - auth, - ); } // Clear the active login if it matches this attempt. It may have been replaced or cancelled. @@ -1459,7 +1342,7 @@ impl CodexMessageProcessor { replace_cloud_requirements_loader( cloud_requirements.as_ref(), auth_manager.clone(), - chatgpt_base_url.clone(), + chatgpt_base_url, codex_home, ); sync_default_client_residency_requirement( @@ -1469,24 +1352,15 @@ impl CodexMessageProcessor { .await; let auth = auth_manager.auth_cached(); - let (workspace_role, is_workspace_owner) = - cached_workspace_role_and_owner_for_auth(auth.as_ref()); - let payload_v2 = account_updated_notification_for_auth( - auth.as_ref(), - workspace_role, - is_workspace_owner, - ); + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; outgoing_clone .send_server_notification(ServerNotification::AccountUpdated( payload_v2, )) .await; - spawn_live_workspace_role_update_for_auth( - outgoing_clone.clone(), - auth_manager.clone(), - chatgpt_base_url.clone(), - auth, - ); } let mut guard = active_login.lock().await; @@ -1635,7 +1509,6 @@ impl CodexMessageProcessor { self.current_account_updated_notification(), )) .await; - self.spawn_live_workspace_role_update(self.auth_manager.auth_cached()); } async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { @@ -1673,8 +1546,6 @@ impl CodexMessageProcessor { let payload_v2 = AccountUpdatedNotification { auth_mode: current_auth_method, plan_type: None, - workspace_role: None, - is_workspace_owner: None, }; self.outgoing .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) @@ -1773,16 +1644,13 @@ impl CodexMessageProcessor { if !requires_openai_auth { let response = GetAccountResponse { account: None, - workspace_role: None, - is_workspace_owner: None, requires_openai_auth, }; self.outgoing.send_response(request_id, response).await; return; } - let auth = self.auth_manager.auth_cached(); - let account = match auth.as_ref() { + let account = match self.auth_manager.auth_cached() { Some(auth) => match auth.auth_mode() { CoreAuthMode::ApiKey => Some(Account::ApiKey {}), CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => { @@ -1809,17 +1677,12 @@ impl CodexMessageProcessor { }, None => None, }; - let (workspace_role, is_workspace_owner) = - cached_workspace_role_and_owner_for_auth(auth.as_ref()); let response = GetAccountResponse { account, - workspace_role, - is_workspace_owner, requires_openai_auth, }; self.outgoing.send_response(request_id, response).await; - self.spawn_live_workspace_role_update(auth); } async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { @@ -1837,11 +1700,6 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } Err(error) => { - tracing::warn!( - ?request_id, - error = %error.message, - "account/rateLimits/read request failed" - ); self.outgoing.send_error(request_id, error).await; } } @@ -2236,6 +2094,7 @@ impl CodexMessageProcessor { experimental_raw_events, personality, ephemeral, + session_start_source, persist_extended_history, } = params; let mut typesafe_overrides = self.build_thread_config_overrides( @@ -2277,6 +2136,7 @@ impl CodexMessageProcessor { config, typesafe_overrides, dynamic_tools, + session_start_source, persist_extended_history, service_name, experimental_raw_events, @@ -2352,6 +2212,7 @@ impl CodexMessageProcessor { config_overrides: Option>, typesafe_overrides: ConfigOverrides, dynamic_tools: Option>, + session_start_source: Option, persist_extended_history: bool, service_name: Option, experimental_raw_events: bool, @@ -2475,6 +2336,12 @@ impl CodexMessageProcessor { .thread_manager .start_thread_with_tools_and_service_name( config, + match session_start_source + .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) + { + codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New, + codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, + }, core_dynamic_tools, persist_extended_history, service_name, @@ -3542,40 +3409,6 @@ impl CodexMessageProcessor { } } - async fn thread_add_credits_nudge_email( - &self, - request_id: ConnectionRequestId, - params: ThreadAddCreditsNudgeEmailParams, - ) { - let ThreadAddCreditsNudgeEmailParams { thread_id } = params; - - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(v) => v, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match self - .submit_core_op(&request_id, thread.as_ref(), Op::SendAddCreditsNudgeEmail) - .await - { - Ok(_) => { - self.outgoing - .send_response(request_id, ThreadAddCreditsNudgeEmailResponse {}) - .await; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to request add-credits nudge email: {err}"), - ) - .await; - } - } - } - async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) { let ThreadListParams { cursor, @@ -4443,7 +4276,7 @@ impl CodexMessageProcessor { thread.preview = preview_from_rollout_items(items); Ok(thread) } - InitialHistory::New => Err(format!( + InitialHistory::New | InitialHistory::Cleared => Err(format!( "failed to build resume response for thread {thread_id}: initial history missing" )), }; @@ -9114,7 +8947,7 @@ pub(crate) async fn read_rollout_items_from_rollout( path: &Path, ) -> std::io::Result> { let items = match RolloutRecorder::get_rollout_history(path).await? { - InitialHistory::New => Vec::new(), + InitialHistory::New | InitialHistory::Cleared => Vec::new(), InitialHistory::Forked(items) => items, InitialHistory::Resumed(resumed) => resumed.history, }; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 37e704663a..d4e4bde063 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -735,7 +735,6 @@ mod tests { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(PlanType::Plus), }, }); @@ -755,7 +754,6 @@ mod tests { }, "secondary": null, "credits": null, - "spendControl": null, "planType": "plus" } }, @@ -771,8 +769,6 @@ mod tests { let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { auth_mode: Some(AuthMode::ApiKey), plan_type: None, - workspace_role: None, - is_workspace_owner: None, }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); @@ -781,9 +777,7 @@ mod tests { "method": "account/updated", "params": { "authMode": "apikey", - "planType": null, - "workspaceRole": null, - "isWorkspaceOwner": null + "planType": null }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 78bc42a532..99334f0770 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -60,11 +60,6 @@ impl ChatGptAuthFixture { self } - pub fn is_org_owner(mut self, is_org_owner: bool) -> Self { - self.claims.is_org_owner = Some(is_org_owner); - self - } - pub fn email(mut self, email: impl Into) -> Self { self.claims.email = Some(email.into()); self @@ -87,7 +82,6 @@ pub struct ChatGptIdTokenClaims { pub plan_type: Option, pub chatgpt_user_id: Option, pub chatgpt_account_id: Option, - pub is_org_owner: Option, } impl ChatGptIdTokenClaims { @@ -114,11 +108,6 @@ impl ChatGptIdTokenClaims { self.chatgpt_account_id = Some(chatgpt_account_id.into()); self } - - pub fn is_org_owner(mut self, is_org_owner: bool) -> Self { - self.is_org_owner = Some(is_org_owner); - self - } } pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { @@ -137,9 +126,6 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { if let Some(chatgpt_account_id) = &claims.chatgpt_account_id { auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id)); } - if let Some(is_org_owner) = claims.is_org_owner { - auth_payload.insert("is_org_owner".to_string(), json!(is_org_owner)); - } if !auth_payload.is_empty() { payload.insert( "https://api.openai.com/auth".to_string(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 0130966213..03c2284b84 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -58,7 +58,6 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadForkParams; @@ -415,16 +414,6 @@ impl McpProcess { self.send_request("thread/shellCommand", params).await } - /// Send a `thread/addCreditsNudgeEmail` JSON-RPC request. - pub async fn send_thread_add_credits_nudge_email_request( - &mut self, - params: ThreadAddCreditsNudgeEmailParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("thread/addCreditsNudgeEmail", params) - .await - } - /// Send a `thread/rollback` JSON-RPC request. pub async fn send_thread_rollback_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index dc8f7eba30..3c88bcb7a4 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -28,7 +28,6 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::WorkspaceRole; use codex_config::types::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use codex_protocol::account::PlanType as AccountPlanType; @@ -56,7 +55,6 @@ struct CreateConfigTomlParams { forced_workspace_id: Option, requires_openai_auth: Option, base_url: Option, - chatgpt_base_url: Option, } fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { @@ -64,9 +62,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: let base_url = params .base_url .unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string()); - let chatgpt_base_url = params - .chatgpt_base_url - .unwrap_or_else(|| "http://127.0.0.1:0/backend-api".to_string()); let forced_line = if let Some(method) = params.forced_method { format!("forced_login_method = \"{method}\"\n") } else { @@ -87,7 +82,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: model = "mock-model" approval_policy = "never" sandbox_mode = "danger-full-access" -chatgpt_base_url = "{chatgpt_base_url}" {forced_line} {forced_workspace_line} @@ -128,49 +122,6 @@ async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) { .await; } -async fn mock_accounts_check_role(server: &MockServer, account_id: &str, role: &str) { - Mock::given(method("GET")) - .and(path("/backend-api/accounts/check/v4-2023-04-27")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "accounts": { - account_id: { - "account": { - "account_user_role": role, - } - } - }, - "account_ordering": [account_id], - }))) - .mount(server) - .await; -} - -async fn mock_slow_accounts_check_role( - server: &MockServer, - account_id: &str, - role: &str, - delay: Duration, -) { - Mock::given(method("GET")) - .and(path("/backend-api/accounts/check/v4-2023-04-27")) - .respond_with( - ResponseTemplate::new(200) - .set_delay(delay) - .set_body_json(json!({ - "accounts": { - account_id: { - "account": { - "account_user_role": role, - } - } - }, - "account_ordering": [account_id], - })), - ) - .mount(server) - .await; -} - async fn mock_device_code_token_success(server: &MockServer) { Mock::given(method("POST")) .and(path("/api/accounts/deviceauth/token")) @@ -270,12 +221,10 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { CreateConfigTomlParams { requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), - chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), ..Default::default() }, )?; write_models_cache(codex_home.path())?; - mock_accounts_check_role(&mock_server, "org-embedded", "standard-user").await; let access_token = encode_id_token( &ChatGptIdTokenClaims::new() @@ -313,20 +262,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { }; assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens)); assert_eq!(payload.plan_type, Some(AccountPlanType::Pro)); - assert_eq!(payload.workspace_role, None); - assert_eq!(payload.is_workspace_owner, None); - - let note = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("account/updated"), - ) - .await??; - let parsed: ServerNotification = note.try_into()?; - let ServerNotification::AccountUpdated(payload) = parsed else { - bail!("unexpected notification: {parsed:?}"); - }; - assert_eq!(payload.workspace_role, Some(WorkspaceRole::StandardUser)); - assert_eq!(payload.is_workspace_owner, Some(false)); let get_id = mcp .send_get_account_request(GetAccountParams { @@ -346,8 +281,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { email: "embedded@example.com".to_string(), plan_type: AccountPlanType::Pro, }), - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: true, } ); @@ -415,8 +348,6 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { email: "embedded@example.com".to_string(), plan_type: AccountPlanType::Pro, }), - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: true, } ); @@ -1574,8 +1505,6 @@ async fn get_account_with_api_key() -> Result<()> { let expected = GetAccountResponse { account: Some(Account::ApiKey {}), - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: true, }; assert_eq!(received, expected); @@ -1610,8 +1539,6 @@ async fn get_account_when_auth_not_required() -> Result<()> { let expected = GetAccountResponse { account: None, - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: false, }; assert_eq!(received, expected); @@ -1632,8 +1559,7 @@ async fn get_account_with_chatgpt() -> Result<()> { codex_home.path(), ChatGptAuthFixture::new("access-chatgpt") .email("user@example.com") - .plan_type("pro") - .is_org_owner(/*is_org_owner*/ true), + .plan_type("pro"), AuthCredentialsStoreMode::File, )?; @@ -1657,190 +1583,12 @@ async fn get_account_with_chatgpt() -> Result<()> { email: "user@example.com".to_string(), plan_type: AccountPlanType::Pro, }), - workspace_role: None, - is_workspace_owner: Some(true), requires_openai_auth: true, }; assert_eq!(received, expected); Ok(()) } -#[tokio::test] -async fn get_account_with_chatgpt_emits_workspace_role_from_accounts_check() -> Result<()> { - let codex_home = TempDir::new()?; - let mock_server = MockServer::start().await; - create_config_toml( - codex_home.path(), - CreateConfigTomlParams { - requires_openai_auth: Some(true), - chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), - ..Default::default() - }, - )?; - mock_accounts_check_role(&mock_server, "org-embedded", "account-owner").await; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("access-chatgpt") - .account_id("org-embedded") - .email("user@example.com") - .plan_type("pro") - .chatgpt_account_id("org-embedded"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let params = GetAccountParams { - refresh_token: false, - }; - let request_id = mcp.send_get_account_request(params).await?; - - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let received: GetAccountResponse = to_response(resp)?; - - let expected = GetAccountResponse { - account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), - plan_type: AccountPlanType::Pro, - }), - workspace_role: None, - is_workspace_owner: None, - requires_openai_auth: true, - }; - assert_eq!(received, expected); - - let note = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("account/updated"), - ) - .await??; - let parsed: ServerNotification = note.try_into()?; - let ServerNotification::AccountUpdated(payload) = parsed else { - bail!("unexpected notification: {parsed:?}"); - }; - assert_eq!(payload.workspace_role, Some(WorkspaceRole::AccountOwner)); - assert_eq!(payload.is_workspace_owner, Some(true)); - Ok(()) -} - -#[tokio::test] -async fn get_account_with_chatgpt_does_not_guess_workspace_role_from_other_accounts() -> Result<()> -{ - let codex_home = TempDir::new()?; - let mock_server = MockServer::start().await; - create_config_toml( - codex_home.path(), - CreateConfigTomlParams { - requires_openai_auth: Some(true), - chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), - ..Default::default() - }, - )?; - mock_accounts_check_role(&mock_server, "org-other", "account-owner").await; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("access-chatgpt") - .account_id("org-current") - .email("user@example.com") - .plan_type("pro") - .chatgpt_account_id("org-current"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let params = GetAccountParams { - refresh_token: false, - }; - let request_id = mcp.send_get_account_request(params).await?; - - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let received: GetAccountResponse = to_response(resp)?; - - let expected = GetAccountResponse { - account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), - plan_type: AccountPlanType::Pro, - }), - workspace_role: None, - is_workspace_owner: None, - requires_openai_auth: true, - }; - assert_eq!(received, expected); - Ok(()) -} - -#[tokio::test] -async fn get_account_with_chatgpt_does_not_wait_for_accounts_check() -> Result<()> { - let codex_home = TempDir::new()?; - let mock_server = MockServer::start().await; - create_config_toml( - codex_home.path(), - CreateConfigTomlParams { - requires_openai_auth: Some(true), - chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())), - ..Default::default() - }, - )?; - mock_slow_accounts_check_role( - &mock_server, - "org-embedded", - "standard-user", - Duration::from_secs(2), - ) - .await; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("access-chatgpt") - .account_id("org-embedded") - .email("user@example.com") - .plan_type("pro") - .chatgpt_account_id("org-embedded") - .is_org_owner(/*is_org_owner*/ true), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_get_account_request(GetAccountParams { - refresh_token: false, - }) - .await?; - let resp: JSONRPCResponse = timeout( - Duration::from_millis(500), - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let received: GetAccountResponse = to_response(resp)?; - - assert_eq!( - received, - GetAccountResponse { - account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), - plan_type: AccountPlanType::Pro, - }), - workspace_role: None, - is_workspace_owner: Some(true), - requires_openai_auth: true, - } - ); - - Ok(()) -} - #[tokio::test] async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> { let codex_home = TempDir::new()?; @@ -1877,8 +1625,6 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result email: "user@example.com".to_string(), plan_type: AccountPlanType::Unknown, }), - workspace_role: None, - is_workspace_owner: None, requires_openai_auth: true, }; assert_eq!(received, expected); diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 99e33eb6b9..5c04cc3c44 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -569,6 +569,55 @@ model = "gpt-old" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config( + &temp_dir, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + assert_eq!(write.status, WriteStatus::Ok); + + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + assert_eq!(read.config.model.as_deref(), Some("gpt-new")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_value_write_rejects_version_conflict() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 4723c6708d..1bb74ccc6b 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -32,7 +32,6 @@ mod request_user_input; mod review; mod safety_check_downgrade; mod skills_list; -mod thread_add_credits_nudge_email; mod thread_archive; mod thread_fork; mod thread_list; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 612b627420..203d664940 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -133,10 +133,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { } } } - ], - "spend_control": { - "reached": true - } + ] }); Mock::given(method("GET")) @@ -175,7 +172,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, - spend_control: Some(codex_app_server_protocol::SpendControlSnapshot { reached: true }), plan_type: Some(AccountPlanType::Pro), }, rate_limits_by_limit_id: Some( @@ -196,9 +192,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { resets_at: Some(secondary_reset_timestamp), }), credits: None, - spend_control: Some(codex_app_server_protocol::SpendControlSnapshot { - reached: true, - }), plan_type: Some(AccountPlanType::Pro), }, ), @@ -214,7 +207,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(AccountPlanType::Pro), }, ), 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 6bd862eb62..0a2bbe0df8 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -241,6 +241,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( developer_instructions: None, personality: None, ephemeral: None, + session_start_source: None, dynamic_tools: None, mock_experimental_field: None, experimental_raw_events: false, diff --git a/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs b/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs deleted file mode 100644 index 14da550e09..0000000000 --- a/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs +++ /dev/null @@ -1,96 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_mock_responses_server_sequence; -use app_test_support::to_response; -use codex_app_server_protocol::AddCreditsNudgeEmailNotification; -use codex_app_server_protocol::AddCreditsNudgeEmailResult; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use pretty_assertions::assert_eq; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test] -async fn thread_add_credits_nudge_email_submits_core_op_and_emits_completion() -> Result<()> { - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - - let server = create_mock_responses_server_sequence(vec![]).await; - create_config_toml(codex_home.as_path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.as_path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let start_id = mcp - .send_thread_start_request(ThreadStartParams::default()) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - - let nudge_id = mcp - .send_thread_add_credits_nudge_email_request(ThreadAddCreditsNudgeEmailParams { - thread_id: thread.id.clone(), - }) - .await?; - let nudge_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(nudge_id)), - ) - .await??; - let _: ThreadAddCreditsNudgeEmailResponse = - to_response::(nudge_resp)?; - - let notification: AddCreditsNudgeEmailNotification = serde_json::from_value( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("account/addCreditsNudgeEmail/completed"), - ) - .await?? - .params - .expect("account/addCreditsNudgeEmail/completed params"), - )?; - - assert_eq!(notification.thread_id, thread.id); - assert_eq!( - notification.result, - AddCreditsNudgeEmailResult::Failed { - message: "codex account authentication required to notify workspace owner".to_string(), - } - ); - - Ok(()) -} - -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - std::fs::write( - codex_home.join("config.toml"), - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 04c06f334b..6049c1697b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,10 +1,8 @@ -use crate::types::AccountsCheckV4Response; use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitStatusPayload; use crate::types::TurnAttemptsSiblingTurnsResponse; -use crate::types::WorkspaceRole; use anyhow::Result; use codex_client::build_reqwest_client_with_custom_ca; use codex_login::CodexAuth; @@ -13,7 +11,6 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; -use codex_protocol::protocol::SpendControlSnapshot; use reqwest::StatusCode; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; @@ -23,10 +20,6 @@ use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; use serde::de::DeserializeOwned; use std::fmt; -use std::time::Duration; - -const BACKEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const ACCOUNTS_CHECK_V4_VERSION: &str = "v4-2023-04-27"; #[derive(Debug)] pub enum RequestError { @@ -266,55 +259,12 @@ impl Client { PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), }; - let req = self - .http - .get(&url) - .headers(self.headers()) - .timeout(BACKEND_REQUEST_TIMEOUT); + let req = self.http.get(&url).headers(self.headers()); let (body, ct) = self.exec_request(req, "GET", &url).await?; let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?; Ok(Self::rate_limit_snapshots_from_payload(payload)) } - pub async fn send_add_credits_nudge_email(&self) -> std::result::Result<(), RequestError> { - let url = match self.path_style { - PathStyle::CodexApi => { - format!( - "{}/api/codex/accounts/send_add_credits_nudge_email", - self.base_url - ) - } - PathStyle::ChatGptApi => { - format!("{}/accounts/send_add_credits_nudge_email", self.base_url) - } - }; - let req = self - .http - .post(&url) - .headers(self.headers()) - .timeout(BACKEND_REQUEST_TIMEOUT); - let _ = self.exec_request_detailed(req, "POST", &url).await?; - Ok(()) - } - - pub async fn get_current_workspace_role(&self) -> Result> { - if self.path_style != PathStyle::ChatGptApi { - return Ok(None); - } - let url = format!( - "{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}", - self.base_url - ); - let req = self - .http - .get(&url) - .headers(self.headers()) - .timeout(BACKEND_REQUEST_TIMEOUT); - let (body, ct) = self.exec_request(req, "GET", &url).await?; - let payload: AccountsCheckV4Response = self.decode_json(&url, &ct, &body)?; - Ok(payload.current_workspace_role(self.chatgpt_account_id.as_deref())) - } - pub async fn list_tasks( &self, limit: Option, @@ -447,23 +397,20 @@ impl Client { payload: RateLimitStatusPayload, ) -> Vec { let plan_type = Some(Self::map_plan_type(payload.plan_type)); - let spend_control = payload.spend_control.map(|details| *details); let mut snapshots = vec![Self::make_rate_limit_snapshot( Some("codex".to_string()), /*limit_name*/ None, - payload.rate_limit.map(|details| *details), - payload.credits.map(|details| *details), - spend_control, + payload.rate_limit.flatten().map(|details| *details), + payload.credits.flatten().map(|details| *details), plan_type, )]; - if let Some(additional) = payload.additional_rate_limits { + if let Some(additional) = payload.additional_rate_limits.flatten() { snapshots.extend(additional.into_iter().map(|details| { Self::make_rate_limit_snapshot( Some(details.metered_feature), Some(details.limit_name), details.rate_limit.flatten().map(|rate_limit| *rate_limit), /*credits*/ None, - /*spend_control*/ None, plan_type, ) })); @@ -476,7 +423,6 @@ impl Client { limit_name: Option, rate_limit: Option, credits: Option, - spend_control: Option, plan_type: Option, ) -> RateLimitSnapshot { let (primary, secondary) = match rate_limit { @@ -492,7 +438,6 @@ impl Client { primary, secondary, credits: Self::map_credits(credits), - spend_control: Self::map_spend_control(spend_control), plan_type, } } @@ -522,15 +467,6 @@ impl Client { }) } - fn map_spend_control( - spend_control: Option, - ) -> Option { - let details = spend_control?; - Some(SpendControlSnapshot { - reached: details.reached, - }) - } - fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType { match plan_type { crate::types::PlanType::Free => AccountPlanType::Free, @@ -567,6 +503,7 @@ impl Client { #[cfg(test)] mod tests { use super::*; + use codex_backend_openapi_models::models::AdditionalRateLimitDetails; use pretty_assertions::assert_eq; #[test] @@ -585,7 +522,7 @@ mod tests { fn usage_payload_maps_primary_and_additional_rate_limits() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Pro, - rate_limit: Some(Box::new(crate::types::RateLimitStatusDetails { + rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { used_percent: 42, limit_window_seconds: 300, @@ -599,8 +536,8 @@ mod tests { reset_at: 456, }))), ..Default::default() - })), - additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails { + }))), + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { @@ -613,16 +550,13 @@ mod tests { secondary_window: None, ..Default::default() }))), - }]), - credits: Some(Box::new(crate::types::CreditStatusDetails { + }])), + credits: Some(Some(Box::new(crate::types::CreditStatusDetails { has_credits: true, unlimited: false, balance: Some(Some("9.99".to_string())), ..Default::default() - })), - spend_control: Some(Box::new(crate::types::SpendControlStatusDetails { - reached: true, - })), + }))), }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -646,10 +580,6 @@ mod tests { balance: Some("9.99".to_string()), }) ); - assert_eq!( - snapshots[0].spend_control, - Some(SpendControlSnapshot { reached: true }) - ); assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro)); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); @@ -659,7 +589,6 @@ mod tests { Some(70.0) ); assert_eq!(snapshots[1].credits, None); - assert_eq!(snapshots[1].spend_control, None); assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro)); } @@ -668,13 +597,12 @@ mod tests { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, - additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails { + additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: None, - }]), + }])), credits: None, - spend_control: None, }; let snapshots = Client::rate_limit_snapshots_from_payload(payload); @@ -682,7 +610,6 @@ mod tests { assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex")); assert_eq!(snapshots[0].limit_name, None); assert_eq!(snapshots[0].primary, None); - assert_eq!(snapshots[0].spend_control, None); assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other")); assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other")); } @@ -700,7 +627,6 @@ mod tests { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(AccountPlanType::Pro), }, RateLimitSnapshot { @@ -713,7 +639,6 @@ mod tests { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(AccountPlanType::Pro), }, ]; @@ -725,46 +650,4 @@ mod tests { .unwrap_or_else(|| snapshots[0].clone()); assert_eq!(preferred.limit_id.as_deref(), Some("codex")); } - - #[test] - fn add_credits_nudge_email_uses_expected_paths() { - let codex_api = Client::new("https://example.com").expect("codex api client"); - assert_eq!( - match codex_api.path_style { - PathStyle::CodexApi => format!( - "{}/api/codex/accounts/send_add_credits_nudge_email", - codex_api.base_url - ), - PathStyle::ChatGptApi => unreachable!("plain host should use codex api paths"), - }, - "https://example.com/api/codex/accounts/send_add_credits_nudge_email" - ); - - let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client"); - assert_eq!( - match chatgpt_api.path_style { - PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"), - PathStyle::ChatGptApi => format!( - "{}/accounts/send_add_credits_nudge_email", - chatgpt_api.base_url - ), - }, - "https://chatgpt.com/backend-api/accounts/send_add_credits_nudge_email" - ); - } - - #[test] - fn current_workspace_role_uses_expected_path() { - let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client"); - assert_eq!( - match chatgpt_api.path_style { - PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"), - PathStyle::ChatGptApi => format!( - "{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}", - chatgpt_api.base_url - ), - }, - "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" - ); - } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 44f5c36ffb..397c2a2cd4 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -3,11 +3,9 @@ pub(crate) mod types; pub use client::Client; pub use client::RequestError; -pub use types::AccountsCheckV4Response; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigFileResponse; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; pub use types::TurnAttemptsSiblingTurnsResponse; -pub use types::WorkspaceRole; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 2e7d368bd0..22e0984cda 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,115 +1,17 @@ -pub use codex_backend_openapi_models::models::AdditionalRateLimitDetails; pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; pub use codex_backend_openapi_models::models::PlanType; pub use codex_backend_openapi_models::models::RateLimitStatusDetails; +pub use codex_backend_openapi_models::models::RateLimitStatusPayload; pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; -use serde::Serialize; use serde::de::Deserializer; use serde_json::Value; use std::collections::HashMap; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WorkspaceRole { - AccountOwner, - AccountAdmin, - StandardUser, -} - -impl WorkspaceRole { - pub fn from_api_str(value: &str) -> Option { - match value { - "account-owner" | "account_owner" => Some(Self::AccountOwner), - "account-admin" | "account_admin" => Some(Self::AccountAdmin), - "standard-user" | "standard_user" | "member" => Some(Self::StandardUser), - _ => None, - } - } -} - -#[derive(Clone, Debug, Default, Deserialize)] -pub struct AccountsCheckV4Response { - #[serde(default)] - pub accounts: HashMap, - #[serde(default)] - pub account_ordering: Vec, -} - -impl AccountsCheckV4Response { - pub fn current_workspace_role( - &self, - current_account_id: Option<&str>, - ) -> Option { - let account = if let Some(account_id) = current_account_id { - self.accounts.get(account_id)? - } else { - self.account_ordering - .iter() - .find_map(|account_id| self.accounts.get(account_id)) - .or_else(|| { - if self.accounts.len() == 1 { - self.accounts.values().next() - } else { - None - } - })? - }; - account - .account - .as_ref() - .and_then(|account| account.account_user_role.as_deref()) - .and_then(WorkspaceRole::from_api_str) - } -} - -#[derive(Clone, Debug, Default, Deserialize)] -pub struct AccountsCheckV4AccountItem { - #[serde(default)] - pub account: Option, -} - -#[derive(Clone, Debug, Default, Deserialize)] -pub struct AccountsCheckV4Account { - #[serde(default, rename = "account_user_role")] - pub account_user_role: Option, -} - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct RateLimitStatusPayload { - #[serde(rename = "plan_type")] - pub plan_type: PlanType, - #[serde( - rename = "rate_limit", - default, - skip_serializing_if = "Option::is_none" - )] - pub rate_limit: Option>, - #[serde(rename = "credits", default, skip_serializing_if = "Option::is_none")] - pub credits: Option>, - #[serde( - rename = "additional_rate_limits", - default, - skip_serializing_if = "Option::is_none" - )] - pub additional_rate_limits: Option>, - #[serde( - rename = "spend_control", - default, - skip_serializing_if = "Option::is_none" - )] - pub spend_control: Option>, -} - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct SpendControlStatusDetails { - #[serde(rename = "reached")] - pub reached: bool, -} - /// Hand-rolled models for the Cloud Tasks task-details response. /// The generated OpenAPI models are pretty bad. This is a half-step /// towards hand-rolling them. @@ -318,83 +220,6 @@ impl Turn { } } -#[cfg(test)] -mod accounts_check_tests { - use super::AccountsCheckV4Account; - use super::AccountsCheckV4AccountItem; - use super::AccountsCheckV4Response; - use super::WorkspaceRole; - use pretty_assertions::assert_eq; - use std::collections::HashMap; - - #[test] - fn current_workspace_role_prefers_current_account_id() { - let response = AccountsCheckV4Response { - accounts: HashMap::from([ - ( - "workspace-a".to_string(), - AccountsCheckV4AccountItem { - account: Some(AccountsCheckV4Account { - account_user_role: Some("standard-user".to_string()), - }), - }, - ), - ( - "workspace-b".to_string(), - AccountsCheckV4AccountItem { - account: Some(AccountsCheckV4Account { - account_user_role: Some("account-owner".to_string()), - }), - }, - ), - ]), - account_ordering: vec!["workspace-a".to_string(), "workspace-b".to_string()], - }; - - assert_eq!( - response.current_workspace_role(Some("workspace-b")), - Some(WorkspaceRole::AccountOwner) - ); - } - - #[test] - fn current_workspace_role_falls_back_to_account_ordering() { - let response = AccountsCheckV4Response { - accounts: HashMap::from([( - "workspace-a".to_string(), - AccountsCheckV4AccountItem { - account: Some(AccountsCheckV4Account { - account_user_role: Some("account_admin".to_string()), - }), - }, - )]), - account_ordering: vec!["workspace-a".to_string()], - }; - - assert_eq!( - response.current_workspace_role(/*current_account_id*/ None), - Some(WorkspaceRole::AccountAdmin) - ); - } - - #[test] - fn current_workspace_role_does_not_fall_back_when_current_account_missing() { - let response = AccountsCheckV4Response { - accounts: HashMap::from([( - "workspace-a".to_string(), - AccountsCheckV4AccountItem { - account: Some(AccountsCheckV4Account { - account_user_role: Some("account-owner".to_string()), - }), - }, - )]), - account_ordering: vec!["workspace-a".to_string()], - }; - - assert_eq!(response.current_workspace_role(Some("workspace-b")), None); - } -} - impl WorklogMessage { fn is_assistant(&self) -> bool { self.author diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 80772a25aa..1c71dc7502 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -92,7 +92,6 @@ pub fn parse_rate_limit_for_limit( primary, secondary, credits, - spend_control: None, plan_type: None, }) } @@ -156,7 +155,6 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { primary, secondary, credits, - spend_control: None, plan_type: event.plan_type, }) } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9631d6c7e3..55ce13afdc 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,7 +26,6 @@ bm25 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } -codex-account = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } diff --git a/codex-rs/core/src/account.rs b/codex-rs/core/src/account.rs deleted file mode 100644 index 5870c0e2a0..0000000000 --- a/codex-rs/core/src/account.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::config::Config; -use codex_account::AddCreditsNudgeEmailStatus; -use codex_account::SendAddCreditsNudgeEmailError; -use codex_account::WorkspaceOwnership; -use codex_login::AuthManager; -use codex_login::CodexAuth; - -pub async fn send_add_credits_nudge_email( - config: &Config, - auth_manager: &AuthManager, -) -> Result { - codex_account::send_add_credits_nudge_email(config.chatgpt_base_url.clone(), auth_manager).await -} - -pub async fn resolve_workspace_role_and_owner_for_auth( - chatgpt_base_url: &str, - auth: Option<&CodexAuth>, -) -> WorkspaceOwnership { - codex_account::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await -} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea0375b144..cd6f1e84fd 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -599,7 +599,7 @@ impl Codex { let thread_id = match &conversation_history { InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), InitialHistory::Forked(_) => conversation_history.forked_from_id(), - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, }; match thread_id { Some(thread_id) => { @@ -1619,7 +1619,7 @@ impl Session { let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { - InitialHistory::New | InitialHistory::Forked(_) => { + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { let conversation_id = ThreadId::default(); ( conversation_id, @@ -1660,14 +1660,14 @@ impl Session { .count(), ) .unwrap_or(u64::MAX), - InitialHistory::New | InitialHistory::Forked(_) => 0, + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => 0, }; let state_builder = match &initial_history { InitialHistory::Resumed(resumed) => metadata::builder_from_items( resumed.history.as_slice(), resumed.rollout_path.as_path(), ), - InitialHistory::New | InitialHistory::Forked(_) => None, + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, }; // Kick off independent async setup tasks in parallel to reduce startup latency. @@ -2026,7 +2026,7 @@ impl Session { session_telemetry, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - guardian_rejection_rationales: Mutex::new(HashMap::new()), + guardian_rejections: Mutex::new(HashMap::new()), skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), @@ -2199,6 +2199,7 @@ impl Session { InitialHistory::New | InitialHistory::Forked(_) => { codex_hooks::SessionStartSource::Startup } + InitialHistory::Cleared => codex_hooks::SessionStartSource::Clear, }; // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. @@ -2339,7 +2340,7 @@ impl Session { ) }; match conversation_history { - InitialHistory::New => { + InitialHistory::New | InitialHistory::Cleared => { // Defer initial context insertion until the first real turn starts so // turn/start overrides can be merged before we write model-visible context. self.set_previous_turn_settings(/*previous_turn_settings*/ None) @@ -4795,10 +4796,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::set_thread_name(&sess, sub.id.clone(), name).await; false } - Op::SendAddCreditsNudgeEmail => { - handlers::send_add_credits_nudge_email(&sess, &config, sub.id.clone()).await; - false - } Op::RunUserShellCommand { command } => { handlers::run_user_shell_command(&sess, sub.id.clone(), command).await; false @@ -4895,8 +4892,6 @@ mod handlers { use crate::tasks::execute_user_shell_command; use codex_mcp::collect_mcp_snapshot_from_manager; use codex_mcp::compute_auth_statuses; - use codex_protocol::protocol::AddCreditsNudgeEmailResponseEvent; - use codex_protocol::protocol::AddCreditsNudgeEmailStatus; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4941,48 +4936,6 @@ mod handlers { sess.close_unified_exec_processes().await; } - pub async fn send_add_credits_nudge_email( - sess: &Arc, - config: &Config, - sub_id: String, - ) { - match crate::send_add_credits_nudge_email(config, &sess.services.auth_manager).await { - Ok(status) => { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::AddCreditsNudgeEmailResponse( - AddCreditsNudgeEmailResponseEvent { - result: Ok(add_credits_nudge_email_status_to_event(status)), - }, - ), - }) - .await; - } - Err(err) => { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::AddCreditsNudgeEmailResponse( - AddCreditsNudgeEmailResponseEvent { - result: Err(err.to_string()), - }, - ), - }) - .await; - } - } - } - - fn add_credits_nudge_email_status_to_event( - status: crate::AddCreditsNudgeEmailStatus, - ) -> AddCreditsNudgeEmailStatus { - match status { - crate::AddCreditsNudgeEmailStatus::Sent => AddCreditsNudgeEmailStatus::Sent, - crate::AddCreditsNudgeEmailStatus::CooldownActive => { - AddCreditsNudgeEmailStatus::CooldownActive - } - } - } - pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) { sess.send_event_raw(Event { id: sub_id, @@ -7289,7 +7242,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 144d9a971b..4deb7d3cfd 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -39,6 +39,7 @@ use crate::codex::TurnContext; use crate::codex::emit_subagent_session_started; use crate::config::Config; use crate::guardian::GuardianApprovalRequest; +use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request_with_cancel; use crate::guardian::routes_approval_to_guardian; use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT; @@ -459,6 +460,7 @@ async fn handle_exec_approval( let review_rx = spawn_guardian_review( Arc::clone(parent_session), Arc::clone(parent_ctx), + new_guardian_review_id(), GuardianApprovalRequest::Shell { id: call_id.clone(), command, @@ -566,6 +568,7 @@ async fn handle_patch_approval( let review_rx = spawn_guardian_review( Arc::clone(parent_session), Arc::clone(parent_ctx), + new_guardian_review_id(), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), cwd: parent_ctx.cwd.to_path_buf(), @@ -686,6 +689,7 @@ async fn maybe_auto_review_mcp_request_user_input( let review_rx = spawn_guardian_review( Arc::clone(parent_session), Arc::clone(parent_ctx), + new_guardian_review_id(), build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()), /*retry_reason*/ None, review_cancel.clone(), @@ -729,6 +733,7 @@ async fn maybe_auto_review_mcp_request_user_input( fn spawn_guardian_review( session: Arc, turn: Arc, + review_id: String, request: GuardianApprovalRequest, retry_reason: Option, cancel_token: CancellationToken, @@ -745,6 +750,7 @@ fn spawn_guardian_review( let decision = runtime.block_on(review_approval_request_with_cancel( &session, &turn, + review_id, request, retry_reason, cancel_token, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 323b418654..62ee884815 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -10,7 +10,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GuardianAssessmentAction; -use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::McpInvocation; @@ -311,22 +310,26 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f }) .await .expect("timed out waiting for guardian assessment"); + let expected_action = GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf tmp".to_string(), + cwd: "/tmp".into(), + }; + assert!(!assessment_event.id.is_empty()); assert_eq!( - assessment_event, - GuardianAssessmentEvent { - id: "command-item-1".to_string(), - turn_id: parent_ctx.sub_id.clone(), - status: GuardianAssessmentStatus::InProgress, - risk_level: None, - user_authorization: None, - rationale: None, - action: GuardianAssessmentAction::Command { - source: GuardianCommandSource::Shell, - command: "rm -rf tmp".to_string(), - cwd: "/tmp".into(), - }, - } + assessment_event.target_item_id.as_deref(), + Some("command-item-1") ); + assert_eq!(assessment_event.turn_id, parent_ctx.sub_id); + assert_eq!( + assessment_event.status, + GuardianAssessmentStatus::InProgress + ); + assert_eq!(assessment_event.risk_level, None); + assert_eq!(assessment_event.user_authorization, None); + assert_eq!(assessment_event.rationale, None); + assert_eq!(assessment_event.decision_source, None); + assert_eq!(assessment_event.action, expected_action); cancel_token.cancel(); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 66c116ee58..6e901a0346 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2012,7 +2012,6 @@ async fn set_rate_limits_retains_previous_credits() { unlimited: false, balance: Some("10.00".to_string()), }), - spend_control: None, plan_type: Some(codex_protocol::account::PlanType::Plus), }; state.set_rate_limits(initial.clone()); @@ -2031,7 +2030,6 @@ async fn set_rate_limits_retains_previous_credits() { resets_at: Some(1_900), }), credits: None, - spend_control: None, plan_type: None, }; state.set_rate_limits(update.clone()); @@ -2044,7 +2042,6 @@ async fn set_rate_limits_retains_previous_credits() { primary: update.primary.clone(), secondary: update.secondary, credits: initial.credits, - spend_control: None, plan_type: initial.plan_type, }) ); @@ -2121,7 +2118,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { unlimited: false, balance: Some("15.00".to_string()), }), - spend_control: None, plan_type: Some(codex_protocol::account::PlanType::Plus), }; state.set_rate_limits(initial.clone()); @@ -2136,7 +2132,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(codex_protocol::account::PlanType::Pro), }; state.set_rate_limits(update.clone()); @@ -2149,7 +2144,6 @@ async fn set_rate_limits_updates_plan_type_when_present() { primary: update.primary, secondary: update.secondary, credits: initial.credits, - spend_control: None, plan_type: update.plan_type, }) ); @@ -2879,7 +2873,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - guardian_rejection_rationales: Mutex::new(std::collections::HashMap::new()), + guardian_rejections: Mutex::new(std::collections::HashMap::new()), skills_manager, plugins_manager, mcp_manager, @@ -3724,7 +3718,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( session_telemetry: session_telemetry.clone(), models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - guardian_rejection_rationales: Mutex::new(std::collections::HashMap::new()), + guardian_rejections: Mutex::new(std::collections::HashMap::new()), skills_manager, plugins_manager, mcp_manager, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 334757e53f..937a7d6f80 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -650,14 +650,8 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { }, ]); - #[cfg(windows)] - let expected_deny_write_paths = vec![ - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(cwd.join(".codex")) - .expect("absolute .codex"), - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) - .expect("absolute docs"), - ]; - #[cfg(not(windows))] + // The legacy workspace-write root already protects top-level `.codex`, so + // the restricted-token overlay only needs the extra read-only docs carveout. let expected_deny_write_paths = vec![ codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) .expect("absolute docs"), diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index b89d020ca8..c9cc9d9fa4 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -359,15 +359,15 @@ pub(crate) fn guardian_assessment_action( } } -pub(crate) fn guardian_request_id(request: &GuardianApprovalRequest) -> &str { +pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> { match request { GuardianApprovalRequest::Shell { id, .. } | GuardianApprovalRequest::ExecCommand { id, .. } | GuardianApprovalRequest::ApplyPatch { id, .. } - | GuardianApprovalRequest::NetworkAccess { id, .. } - | GuardianApprovalRequest::McpToolCall { id, .. } => id, + | GuardianApprovalRequest::McpToolCall { id, .. } => Some(id), + GuardianApprovalRequest::NetworkAccess { .. } => None, #[cfg(unix)] - GuardianApprovalRequest::Execve { id, .. } => id, + GuardianApprovalRequest::Execve { id, .. } => Some(id), } } diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 9a30b3941b..27f596c296 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -18,6 +18,7 @@ mod review_session; use std::time::Duration; +use codex_protocol::protocol::GuardianAssessmentDecisionSource; use serde::Deserialize; use serde::Serialize; @@ -29,6 +30,7 @@ pub(crate) use review::GuardianApprovalReviewResult; pub(crate) use review::GuardianApprovalReviewStatus; pub(crate) use review::guardian_rejection_message; pub(crate) use review::is_guardian_reviewer_source; +pub(crate) use review::new_guardian_review_id; pub(crate) use review::review_approval_request; pub(crate) use review::review_approval_request_with_cancel; pub(crate) use review::review_approval_request_with_review; @@ -63,6 +65,12 @@ pub(crate) struct GuardianAssessment { pub(crate) rationale: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GuardianRejection { + pub(crate) rationale: String, + pub(crate) source: GuardianAssessmentDecisionSource, +} + #[cfg(test)] use approval_request::format_guardian_action_pretty; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 18ed8a60cd..106eeb7c22 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; @@ -19,8 +20,9 @@ use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::GuardianAssessmentOutcome; +use super::GuardianRejection; use super::approval_request::guardian_assessment_action; -use super::approval_request::guardian_request_id; +use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; @@ -36,20 +38,29 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!( "Otherwise, stop and request user input.", ); -pub(crate) async fn guardian_rejection_message(session: &Session, assessment_id: &str) -> String { - let rationale = session +pub(crate) fn new_guardian_review_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &str) -> String { + let rejection = session .services - .guardian_rejection_rationales + .guardian_rejections .lock() .await - .remove(assessment_id) - .filter(|rationale| !rationale.trim().is_empty()) - .unwrap_or_else(|| "Guardian denied the action without a specific rationale.".to_string()); - format!( - "This action was rejected due to unacceptable risk.\nReason: {}\n{}", - rationale.trim(), - GUARDIAN_REJECTION_INSTRUCTIONS - ) + .remove(review_id) + .filter(|rejection| !rejection.rationale.trim().is_empty()) + .unwrap_or_else(|| GuardianRejection { + rationale: "Guardian denied the action without a specific rationale.".to_string(), + source: GuardianAssessmentDecisionSource::Agent, + }); + match rejection.source { + GuardianAssessmentDecisionSource::Agent => format!( + "This action was rejected due to unacceptable risk.\nReason: {}\n{}", + rejection.rationale.trim(), + GUARDIAN_REJECTION_INSTRUCTIONS + ), + } } #[derive(Debug)] @@ -115,23 +126,26 @@ pub(crate) fn is_guardian_reviewer_source( async fn run_guardian_review( session: Arc, turn: Arc, + review_id: String, request: GuardianApprovalRequest, retry_reason: Option, external_cancel: Option, ) -> GuardianApprovalReviewResult { - let assessment_id = guardian_request_id(&request).to_string(); + let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); session .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: assessment_id.clone(), + id: review_id.clone(), + target_item_id: target_item_id.clone(), turn_id: assessment_turn_id.clone(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: action_summary.clone(), }), ) @@ -145,12 +159,14 @@ async fn run_guardian_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: assessment_id, + id: review_id, + target_item_id, turn_id: assessment_turn_id, status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, rationale: None, + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: action_summary, }), ) @@ -206,12 +222,14 @@ async fn run_guardian_review( .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: assessment_id, + id: review_id, + target_item_id, turn_id: assessment_turn_id, status: GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, rationale: None, + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: action_summary, }), ) @@ -274,23 +292,29 @@ async fn run_guardian_review( rationale: Some(assessment.rationale.clone()), }; { - let mut rationales = session.services.guardian_rejection_rationales.lock().await; + let mut rationales = session.services.guardian_rejections.lock().await; if approved { - rationales.remove(&assessment_id); + rationales.remove(&review_id); } else { - rationales.insert(assessment_id.clone(), assessment.rationale.clone()); + let rejection = GuardianRejection { + rationale: assessment.rationale.clone(), + source: GuardianAssessmentDecisionSource::Agent, + }; + rationales.insert(review_id.clone(), rejection); } } session .send_event( turn.as_ref(), EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: assessment_id, + id: review_id, + target_item_id, turn_id: assessment_turn_id, status, risk_level: Some(assessment.risk_level), user_authorization: Some(assessment.user_authorization), rationale: Some(assessment.rationale.clone()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: terminal_action, }), ) @@ -303,12 +327,14 @@ async fn run_guardian_review( pub(crate) async fn review_approval_request_with_review( session: &Arc, turn: &Arc, + review_id: String, request: GuardianApprovalRequest, retry_reason: Option, ) -> GuardianApprovalReviewResult { run_guardian_review( Arc::clone(session), Arc::clone(turn), + review_id, request, retry_reason, /*external_cancel*/ None, @@ -320,10 +346,11 @@ pub(crate) async fn review_approval_request_with_review( pub(crate) async fn review_approval_request( session: &Arc, turn: &Arc, + review_id: String, request: GuardianApprovalRequest, retry_reason: Option, ) -> ReviewDecision { - review_approval_request_with_review(session, turn, request, retry_reason) + review_approval_request_with_review(session, turn, review_id, request, retry_reason) .await .decision } @@ -331,6 +358,7 @@ pub(crate) async fn review_approval_request( pub(crate) async fn review_approval_request_with_cancel( session: &Arc, turn: &Arc, + review_id: String, request: GuardianApprovalRequest, retry_reason: Option, cancel_token: CancellationToken, @@ -338,6 +366,7 @@ pub(crate) async fn review_approval_request_with_cancel( run_guardian_review( Arc::clone(session), Arc::clone(turn), + review_id, request, retry_reason, Some(cancel_token), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 256977f513..5fe194cac6 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -414,7 +414,7 @@ impl GuardianReviewSessionManager { let snapshot = state.last_committed_fork_snapshot.as_ref()?; match &snapshot.initial_history { InitialHistory::Forked(items) => Some(items.clone()), - InitialHistory::New | InitialHistory::Resumed(_) => None, + _ => None, } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 84c8bad1fb..67c30c2bf0 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -683,6 +683,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { let decision = review_approval_request_with_cancel( &session, &turn, + "review-cancelled-guardian".to_string(), GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), @@ -1248,6 +1249,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> let decision = review_approval_request( &session, &turn, + "review-shell-guardian-error".to_string(), GuardianApprovalRequest::Shell { id: "shell-guardian-error".to_string(), command: vec!["git".to_string(), "push".to_string()], @@ -1295,8 +1297,13 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> }), "denial rationale should not fall back to the generic missing payload error" ); + { + let rationales = session.services.guardian_rejections.lock().await; + assert!(rationales.contains_key("review-shell-guardian-error")); + assert!(!rationales.contains_key("shell-guardian-error")); + } let rejection_message = - guardian_rejection_message(session.as_ref(), "shell-guardian-error").await; + guardian_rejection_message(session.as_ref(), "review-shell-guardian-error").await; assert!( rejection_message.contains("Reason: Automatic approval review failed:") && rejection_message.contains(error_message), @@ -1375,7 +1382,14 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a justification: Some("Inspect repo state before proceeding.".to_string()), }; assert_eq!( - review_approval_request(&session, &turn, initial_request, /*retry_reason*/ None).await, + review_approval_request( + &session, + &turn, + "review-shell-guardian-1".to_string(), + initial_request, + /*retry_reason*/ None + ) + .await, ReviewDecision::Approved ); session @@ -1427,6 +1441,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a review_approval_request( &session_for_second, &turn_for_second, + "review-shell-guardian-2".to_string(), second_request, Some("trunk follow-up".to_string()), ) @@ -1475,6 +1490,7 @@ async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> a let third_decision = review_approval_request( &session, &turn, + "review-shell-guardian-3".to_string(), third_request, Some("parallel follow-up".to_string()), ) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2e41f7cf60..5e62199d2d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,7 +5,6 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] -mod account; mod apply_patch; mod apps; mod arc_monitor; @@ -113,13 +112,7 @@ mod stream_events_utils; pub mod test_support; mod unified_exec; pub mod windows_sandbox; -pub use account::resolve_workspace_role_and_owner_for_auth; -pub use account::send_add_credits_nudge_email; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; -pub use codex_account::AddCreditsNudgeEmailStatus; -pub use codex_account::SendAddCreditsNudgeEmailError; -pub use codex_account::WorkspaceOwnership; -pub use codex_account::WorkspaceRole; pub use codex_protocol::config_types::ModelProviderAuthInfo; mod event_mapping; pub mod review_format; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 46654e35f6..e038f6c1c9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -24,6 +24,7 @@ use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianMcpAnnotations; use crate::guardian::guardian_approval_request_to_json; use crate::guardian::guardian_rejection_message; +use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files; @@ -772,14 +773,16 @@ async fn maybe_request_mcp_tool_approval( .enabled(Feature::ToolCallMcpElicitation); if routes_approval_to_guardian(turn_context) { + let review_id = new_guardian_review_id(); let decision = review_approval_request( sess, turn_context, + review_id.clone(), build_guardian_mcp_tool_review_request(call_id, invocation, metadata), monitor_reason.clone(), ) .await; - let decision = mcp_tool_approval_decision_from_guardian(sess, call_id, decision).await; + let decision = mcp_tool_approval_decision_from_guardian(sess, &review_id, decision).await; apply_mcp_tool_approval_decision( sess, turn_context, @@ -969,7 +972,7 @@ pub(crate) fn build_guardian_mcp_tool_review_request( async fn mcp_tool_approval_decision_from_guardian( sess: &Session, - call_id: &str, + review_id: &str, decision: ReviewDecision, ) -> McpToolApprovalDecision { match decision { @@ -978,7 +981,7 @@ async fn mcp_tool_approval_decision_from_guardian( | ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept, ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession, ReviewDecision::Denied => McpToolApprovalDecision::Decline { - message: Some(guardian_rejection_message(sess, call_id).await), + message: Some(guardian_rejection_message(sess, review_id).await), }, ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None }, } diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 92913ffe23..af99b43c5d 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -816,21 +816,22 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() { assert_eq!( mcp_tool_approval_decision_from_guardian( session.as_ref(), - "approval-id", + "review-id", ReviewDecision::Approved ) .await, McpToolApprovalDecision::Accept ); - session - .services - .guardian_rejection_rationales - .lock() - .await - .insert("approval-id".to_string(), "too risky".to_string()); + session.services.guardian_rejections.lock().await.insert( + "review-id".to_string(), + crate::guardian::GuardianRejection { + rationale: "too risky".to_string(), + source: codex_protocol::protocol::GuardianAssessmentDecisionSource::Agent, + }, + ); let denial = mcp_tool_approval_decision_from_guardian( session.as_ref(), - "approval-id", + "review-id", ReviewDecision::Denied, ) .await; @@ -845,7 +846,7 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() { assert_eq!( mcp_tool_approval_decision_from_guardian( session.as_ref(), - "approval-id", + "review-id", ReviewDecision::Abort ) .await, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index b64a3b6d6a..2e89585f99 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -7,6 +7,7 @@ use crate::agent::AgentControl; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; +use crate::guardian::GuardianRejection; use crate::mcp::McpManager; use crate::plugins::PluginsManager; use crate::skills_watcher::SkillsWatcher; @@ -47,7 +48,7 @@ pub(crate) struct SessionServices { pub(crate) models_manager: Arc, pub(crate) session_telemetry: SessionTelemetry, pub(crate) tool_approvals: Mutex, - pub(crate) guardian_rejection_rationales: Mutex>, + pub(crate) guardian_rejections: Mutex>, pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index ce01994e4a..206f75060c 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -229,9 +229,6 @@ fn merge_rate_limit_fields( if snapshot.credits.is_none() { snapshot.credits = previous.and_then(|prior| prior.credits.clone()); } - if snapshot.spend_control.is_none() { - snapshot.spend_control = previous.and_then(|prior| prior.spend_control.clone()); - } if snapshot.plan_type.is_none() { snapshot.plan_type = previous.and_then(|prior| prior.plan_type); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index c9b9e68f11..1af7ccc8f6 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -48,7 +48,6 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { }), secondary: None, credits: None, - spend_control: None, plan_type: None, }); @@ -76,7 +75,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, - spend_control: None, plan_type: None, }); state.set_rate_limits(RateLimitSnapshot { @@ -89,7 +87,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc }), secondary: None, credits: None, - spend_control: None, plan_type: None, }); @@ -121,7 +118,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), - spend_control: None, plan_type: Some(codex_protocol::account::PlanType::Plus), }); @@ -135,7 +131,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other }), secondary: None, credits: None, - spend_control: None, plan_type: None, }); @@ -155,7 +150,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other unlimited: false, balance: Some("50".to_string()), }), - spend_control: None, plan_type: Some(codex_protocol::account::PlanType::Plus), }) ); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index af83785c55..d8a38a8581 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -474,6 +474,7 @@ impl ThreadManager { ) -> CodexResult { Box::pin(self.start_thread_with_tools_and_service_name( config, + InitialHistory::New, dynamic_tools, persist_extended_history, /*metrics_service_name*/ None, @@ -485,6 +486,7 @@ impl ThreadManager { pub async fn start_thread_with_tools_and_service_name( &self, config: Config, + initial_history: InitialHistory, dynamic_tools: Vec, persist_extended_history: bool, metrics_service_name: Option, @@ -492,7 +494,7 @@ impl ThreadManager { ) -> CodexResult { Box::pin(self.state.spawn_thread( config, - InitialHistory::New, + initial_history, Arc::clone(&self.state.auth_manager), self.agent_control(), dynamic_tools, @@ -668,6 +670,7 @@ impl ThreadManager { ForkSnapshot::Interrupted => { let history = match history { InitialHistory::New => InitialHistory::New, + InitialHistory::Cleared => InitialHistory::Cleared, InitialHistory::Forked(history) => InitialHistory::Forked(history), InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), }; @@ -1070,7 +1073,7 @@ fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) })); match history { - InitialHistory::New => InitialHistory::Forked(vec![ + InitialHistory::New | InitialHistory::Cleared => InitialHistory::Forked(vec![ RolloutItem::ResponseItem(interrupted_turn_history_marker()), aborted_event, ]), diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index ffa536c39c..58467357c9 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -1,6 +1,7 @@ use crate::codex::Session; use crate::guardian::GuardianApprovalRequest; use crate::guardian::guardian_rejection_message; +use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::network_policy_decision::denied_network_policy_message; @@ -369,12 +370,13 @@ impl NetworkApprovalService { }; let owner_call = self.resolve_single_active_call().await; let guardian_approval_id = Self::approval_id_for_key(&key); - let approval_decision = if routes_approval_to_guardian(&turn_context) { - // TODO(ccunningham): Attach guardian network reviews to the reviewed tool item - // lifecycle instead of this temporary standalone network approval id. + let use_guardian = routes_approval_to_guardian(&turn_context); + let guardian_review_id = use_guardian.then(new_guardian_review_id); + let approval_decision = if let Some(review_id) = guardian_review_id.clone() { review_approval_request( &session, &turn_context, + review_id, GuardianApprovalRequest::NetworkAccess { id: guardian_approval_id.clone(), turn_id: owner_call @@ -487,11 +489,9 @@ impl NetworkApprovalService { } }, ReviewDecision::Denied | ReviewDecision::Abort => { - if routes_approval_to_guardian(&turn_context) { + if let Some(review_id) = guardian_review_id.as_deref() { if let Some(owner_call) = owner_call.as_ref() { - let message = - guardian_rejection_message(session.as_ref(), &guardian_approval_id) - .await; + let message = guardian_rejection_message(session.as_ref(), review_id).await; self.record_call_outcome( &owner_call.registration_id, NetworkApprovalOutcome::DeniedByPolicy(message), diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 308c8b81b9..422832c54e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -11,6 +11,7 @@ use crate::guardian::GuardianApprovalReviewResult; use crate::guardian::GuardianApprovalReviewStatus; use crate::guardian::GuardianAssessmentOutcome; use crate::guardian::guardian_rejection_message; +use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request_with_review; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; @@ -123,6 +124,7 @@ impl ToolOrchestrator { let otel = turn_ctx.session_telemetry.clone(); let otel_tn = &tool_ctx.tool_name; let otel_ci = &tool_ctx.call_id; + let use_guardian = routes_approval_to_guardian(turn_ctx); // 1) Approval let mut already_approved = false; @@ -142,10 +144,12 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ExecApprovalRequirement::NeedsApproval { reason, .. } => { + let guardian_review_id = use_guardian.then(new_guardian_review_id); let approval_ctx = ApprovalCtx { session: &tool_ctx.session, turn: &tool_ctx.turn, call_id: &tool_ctx.call_id, + guardian_review_id: guardian_review_id.clone(), retry_reason: reason, network_approval_context: None, }; @@ -162,9 +166,8 @@ impl ToolOrchestrator { match decision { ReviewDecision::Denied | ReviewDecision::Abort => { - let reason = if routes_approval_to_guardian(turn_ctx) { - guardian_rejection_message(tool_ctx.session.as_ref(), &tool_ctx.call_id) - .await + let reason = if let Some(review_id) = guardian_review_id.as_deref() { + guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await } else { "rejected by user".to_string() }; @@ -298,14 +301,15 @@ impl ToolOrchestrator { .should_bypass_approval(approval_policy, already_approved) && network_approval_context.is_none(); if !bypass_retry_approval { + let guardian_review_id = use_guardian.then(new_guardian_review_id); let approval_ctx = ApprovalCtx { session: &tool_ctx.session, turn: &tool_ctx.turn, call_id: &tool_ctx.call_id, + guardian_review_id: guardian_review_id.clone(), retry_reason: Some(retry_reason), network_approval_context: network_approval_context.clone(), }; - let decision = Self::request_approval( tool, req, @@ -319,12 +323,9 @@ impl ToolOrchestrator { match decision { ReviewDecision::Denied | ReviewDecision::Abort => { - let reason = if routes_approval_to_guardian(turn_ctx) { - guardian_rejection_message( - tool_ctx.session.as_ref(), - &tool_ctx.call_id, - ) - .await + let reason = if let Some(review_id) = guardian_review_id.as_deref() { + guardian_rejection_message(tool_ctx.session.as_ref(), review_id) + .await } else { "rejected by user".to_string() }; @@ -403,6 +404,10 @@ impl ToolOrchestrator { review_approval_request_with_review( approval_ctx.session, approval_ctx.turn, + approval_ctx + .guardian_review_id + .clone() + .expect("guardian review id should be present for guardian approvals"), request, approval_ctx.retry_reason.clone(), ) diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index b2b7a73f8f..51f0cc83d5 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -8,7 +8,6 @@ use crate::exec::ExecCapturePolicy; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; -use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecOptions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; @@ -147,13 +146,15 @@ impl Approvable for ApplyPatchRuntime { let retry_reason = ctx.retry_reason.clone(); let approval_keys = self.approval_keys(req); let changes = req.changes.clone(); + let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { if req.permissions_preapproved && retry_reason.is_none() { return ReviewDecision::Approved; } - if routes_approval_to_guardian(turn) { + if let Some(review_id) = guardian_review_id { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); - return review_approval_request(session, turn, action, retry_reason).await; + return review_approval_request(session, turn, review_id, action, retry_reason) + .await; } if let Some(reason) = retry_reason { let rx_approve = session diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 8e59b800eb..306867b91f 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -12,7 +12,6 @@ use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecCapturePolicy; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; -use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; @@ -166,10 +165,17 @@ impl Approvable for ShellRuntime { let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); + let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if routes_approval_to_guardian(turn) { - return review_approval_request(session, turn, guardian_request, retry_reason) - .await; + if let Some(review_id) = guardian_review_id { + return review_approval_request( + session, + turn, + review_id, + guardian_request, + retry_reason, + ) + .await; } with_cached_approval(&session.services, "shell", keys, move || async move { let available_decisions = None; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 47d1b62539..41195876fc 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -4,6 +4,7 @@ use crate::exec::ExecExpiration; use crate::exec::is_likely_sandbox_denied; use crate::guardian::GuardianApprovalRequest; use crate::guardian::guardian_rejection_message; +use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecOptions; @@ -315,6 +316,11 @@ enum DecisionSource { UnmatchedCommandFallback, } +struct PromptDecision { + decision: ReviewDecision, + guardian_review_id: Option, +} + fn execve_prompt_is_rejected_by_policy( approval_policy: AskForApproval, decision_source: &DecisionSource, @@ -376,7 +382,7 @@ impl CoreShellActionProvider { workdir: &AbsolutePathBuf, stopwatch: &Stopwatch, additional_permissions: Option, - ) -> anyhow::Result { + ) -> anyhow::Result { let command = join_program_and_argv(program, argv); let workdir = workdir.to_path_buf(); let session = self.session.clone(); @@ -384,12 +390,14 @@ impl CoreShellActionProvider { let call_id = self.call_id.clone(); let approval_id = Some(Uuid::new_v4().to_string()); let source = self.tool_name; + let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id); Ok(stopwatch .pause_for(async move { - if routes_approval_to_guardian(&turn) { - return review_approval_request( + if let Some(review_id) = guardian_review_id.clone() { + let decision = review_approval_request( &session, &turn, + review_id, GuardianApprovalRequest::Execve { id: call_id.clone(), source, @@ -401,8 +409,12 @@ impl CoreShellActionProvider { /*retry_reason*/ None, ) .await; + return PromptDecision { + decision, + guardian_review_id, + }; } - session + let decision = session .request_command_approval( &turn, call_id, @@ -415,7 +427,11 @@ impl CoreShellActionProvider { additional_permissions, Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), ) - .await + .await; + PromptDecision { + decision, + guardian_review_id: None, + } }) .await) } @@ -442,10 +458,10 @@ impl CoreShellActionProvider { { EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) } else { - match self + let prompt_decision = self .prompt(program, argv, workdir, &self.stopwatch, prompt_permissions) - .await? - { + .await?; + match prompt_decision.decision { ReviewDecision::Approved | ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { @@ -470,9 +486,10 @@ impl CoreShellActionProvider { } }, ReviewDecision::Denied => { - let message = if routes_approval_to_guardian(&self.turn) { - guardian_rejection_message(self.session.as_ref(), &self.call_id) - .await + let message = if let Some(review_id) = + prompt_decision.guardian_review_id.as_deref() + { + guardian_rejection_message(self.session.as_ref(), review_id).await } else { "User denied execution".to_string() }; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index d8bdfce28c..271c4493a1 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -9,7 +9,6 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; -use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; @@ -146,10 +145,17 @@ impl Approvable for UnifiedExecRuntime<'_> { let cwd = req.cwd.to_path_buf(); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); + let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if routes_approval_to_guardian(turn) { - return review_approval_request(session, turn, guardian_request, retry_reason) - .await; + if let Some(review_id) = guardian_review_id { + return review_approval_request( + session, + turn, + review_id, + guardian_request, + retry_reason, + ) + .await; } with_cached_approval(&session.services, "unified_exec", keys, || async move { let available_decisions = None; diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 9eefb50a2b..a3e64f96ea 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -122,6 +122,13 @@ pub(crate) struct ApprovalCtx<'a> { pub session: &'a Arc, pub turn: &'a Arc, pub call_id: &'a str, + /// Guardian review lifecycle ID for this approval, when guardian is reviewing it. + /// + /// This is separate from `call_id`: `call_id` identifies the tool item under + /// review, while this ID identifies the review itself. Keeping both lets + /// denial handling, overrides, and app-server notifications refer to the + /// review without overloading the tool call ID as a review ID. + pub guardian_review_id: Option, pub retry_reason: Option, pub network_approval_context: Option, } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d8eb1df788..7811654a2e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2375,7 +2375,6 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "spend_control": null, "plan_type": null } }) @@ -2427,7 +2426,6 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, - "spend_control": null, "plan_type": null } }) @@ -2502,7 +2500,6 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, - "spend_control": null, "plan_type": null }); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 8407191ceb..424f6764e6 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1033,7 +1033,6 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, - "spend_control": null, "plan_type": null } }) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f3b281a822..81a0961fa4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use event_processor_with_human_output::EventProcessorWithHumanOutput; @@ -278,7 +279,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let resolved_cwd = cwd.clone(); let config_cwd = match resolved_cwd.as_deref() { - Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?)?, + Some(path) => { + AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?)? + } None => AbsolutePathBuf::current_dir()?, }; diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 057c5d0e7c..b7be9a9f66 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -20,6 +20,7 @@ use crate::schema::SessionStartCommandInput; pub enum SessionStartSource { Startup, Resume, + Clear, } impl SessionStartSource { @@ -27,6 +28,7 @@ impl SessionStartSource { match self { Self::Startup => "startup", Self::Resume => "resume", + Self::Clear => "clear", } } } diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 0709324f64..91d6621029 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -11,6 +11,7 @@ //! - bubblewrap used to construct the filesystem view before exec. use std::collections::BTreeSet; use std::collections::HashSet; +use std::fs; use std::fs::File; use std::os::fd::AsRawFd; use std::path::Path; @@ -273,19 +274,35 @@ fn create_filesystem_args( if !root.exists() { continue; } + // Writable roots are rebound by real target below; mirror that + // for their restricted-read bootstrap mount. Plain read-only + // roots must stay logical because callers may execute those + // paths inside bwrap, such as Bazel runfiles helper binaries. + let mount_root = if writable_roots + .iter() + .any(|writable_root| root.starts_with(writable_root.root.as_path())) + { + canonical_target_if_symlinked_path(&root).unwrap_or(root) + } else { + root + }; args.push("--ro-bind".to_string()); - args.push(path_to_string(&root)); - args.push(path_to_string(&root)); + args.push(path_to_string(&mount_root)); + args.push(path_to_string(&mount_root)); } } args }; let mut preserved_files = Vec::new(); - let allowed_write_paths: Vec = writable_roots - .iter() - .map(|writable_root| writable_root.root.as_path().to_path_buf()) - .collect(); + let mut allowed_write_paths = Vec::with_capacity(writable_roots.len()); + for writable_root in &writable_roots { + let root = writable_root.root.as_path(); + allowed_write_paths.push(root.to_path_buf()); + if let Some(target) = canonical_target_if_symlinked_path(root) { + allowed_write_paths.push(target); + } + } let unreadable_paths: HashSet = unreadable_roots .iter() .map(|path| path.as_path().to_path_buf()) @@ -321,6 +338,7 @@ fn create_filesystem_args( for writable_root in &sorted_writable_roots { let root = writable_root.root.as_path(); + let symlink_target = canonical_target_if_symlinked_path(root); // If a denied ancestor was already masked, recreate any missing mount // target parents before binding the narrower writable descendant. if let Some(masking_root) = unreadable_roots @@ -332,9 +350,10 @@ fn create_filesystem_args( append_mount_target_parent_dir_args(&mut args, root, masking_root); } + let mount_root = symlink_target.as_deref().unwrap_or(root); args.push("--bind".to_string()); - args.push(path_to_string(root)); - args.push(path_to_string(root)); + args.push(path_to_string(mount_root)); + args.push(path_to_string(mount_root)); let mut read_only_subpaths: Vec = writable_root .read_only_subpaths @@ -342,6 +361,9 @@ fn create_filesystem_args( .map(|path| path.as_path().to_path_buf()) .filter(|path| !unreadable_paths.contains(path)) .collect(); + if let Some(target) = &symlink_target { + read_only_subpaths = remap_paths_for_symlink_target(read_only_subpaths, root, target); + } read_only_subpaths.sort_by_key(|path| path_depth(path)); for subpath in read_only_subpaths { append_read_only_subpath_args(&mut args, &subpath, &allowed_write_paths); @@ -351,6 +373,10 @@ fn create_filesystem_args( .filter(|path| path.as_path().starts_with(root)) .map(|path| path.as_path().to_path_buf()) .collect(); + if let Some(target) = &symlink_target { + nested_unreadable_roots = + remap_paths_for_symlink_target(nested_unreadable_roots, root, target); + } nested_unreadable_roots.sort_by_key(|path| path_depth(path)); for unreadable_root in nested_unreadable_roots { append_unreadable_root_args( @@ -396,6 +422,52 @@ fn path_depth(path: &Path) -> usize { path.components().count() } +fn canonical_target_if_symlinked_path(path: &Path) -> Option { + let mut current = PathBuf::new(); + for component in path.components() { + use std::path::Component; + match component { + Component::RootDir => { + current.push(Path::new("/")); + continue; + } + Component::CurDir => continue, + Component::ParentDir => { + current.pop(); + continue; + } + Component::Normal(part) => current.push(part), + Component::Prefix(_) => continue, + } + + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(_) => return None, + }; + if metadata.file_type().is_symlink() { + let target = fs::canonicalize(path).ok()?; + if target.as_path() == path { + return None; + } + return Some(target); + } + } + None +} + +fn remap_paths_for_symlink_target(paths: Vec, root: &Path, target: &Path) -> Vec { + paths + .into_iter() + .map(|path| { + if let Ok(relative) = path.strip_prefix(root) { + target.join(relative) + } else { + path + } + }) + .collect() +} + fn normalize_command_cwd_for_bwrap(command_cwd: &Path) -> PathBuf { command_cwd .canonicalize() @@ -427,10 +499,15 @@ fn append_read_only_subpath_args( subpath: &Path, allowed_write_paths: &[PathBuf], ) { - if let Some(symlink_path) = find_symlink_in_path(subpath, allowed_write_paths) { + if let Some(target) = canonical_target_for_symlink_in_path(subpath, allowed_write_paths) { + // bwrap takes `--ro-bind `. Use the resolved target + // for both operands so a protected symlinked directory is remounted + // read-only in place instead of binding onto the symlink path itself. + let mount_source = path_to_string(&target); + let mount_destination = path_to_string(&target); args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&symlink_path)); + args.push(mount_source); + args.push(mount_destination); return; } @@ -458,11 +535,16 @@ fn append_unreadable_root_args( unreadable_root: &Path, allowed_write_paths: &[PathBuf], ) -> Result<()> { - if let Some(symlink_path) = find_symlink_in_path(unreadable_root, allowed_write_paths) { - args.push("--ro-bind".to_string()); - args.push("/dev/null".to_string()); - args.push(path_to_string(&symlink_path)); - return Ok(()); + if let Some(target) = canonical_target_for_symlink_in_path(unreadable_root, allowed_write_paths) + { + // Apply unreadable handling to the resolved symlink target, not the + // logical symlink path, to avoid file-vs-directory bind mismatches. + return append_existing_unreadable_path_args( + args, + preserved_files, + &target, + allowed_write_paths, + ); } if !unreadable_root.exists() { @@ -476,6 +558,20 @@ fn append_unreadable_root_args( return Ok(()); } + append_existing_unreadable_path_args( + args, + preserved_files, + unreadable_root, + allowed_write_paths, + ) +} + +fn append_existing_unreadable_path_args( + args: &mut Vec, + preserved_files: &mut Vec, + unreadable_root: &Path, + allowed_write_paths: &[PathBuf], +) -> Result<()> { if unreadable_root.is_dir() { let mut writable_descendants: Vec<&Path> = allowed_write_paths .iter() @@ -525,12 +621,10 @@ fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) - .any(|root| path.starts_with(root)) } -/// Find the first symlink along `target_path` that is also under a writable root. -/// -/// This blocks symlink replacement attacks where a protected path is a symlink -/// inside a writable root (e.g., `.codex -> ./decoy`). In that case we mount -/// `/dev/null` on the symlink itself to prevent rewiring it. -fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) -> Option { +fn canonical_target_for_symlink_in_path( + target_path: &Path, + allowed_write_paths: &[PathBuf], +) -> Option { let mut current = PathBuf::new(); for component in target_path.components() { @@ -557,7 +651,7 @@ fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) -> if metadata.file_type().is_symlink() && is_within_allowed_write_paths(¤t, allowed_write_paths) { - return Some(current); + return fs::canonicalize(¤t).ok(); } } @@ -701,7 +795,13 @@ mod tests { BwrapOptions::default(), ) .expect("create bwrap args"); + let canonical_sandbox_cwd = path_to_string( + &real_root + .canonicalize() + .expect("canonicalize sandbox policy cwd"), + ); let canonical_command_cwd = path_to_string(&canonical_command_cwd); + let link_sandbox_cwd = path_to_string(&link_root); let link_command_cwd = path_to_string(&command_cwd); assert!( @@ -709,12 +809,199 @@ mod tests { .windows(2) .any(|window| { window == ["--chdir", canonical_command_cwd.as_str()] }) ); + assert!(args.args.windows(3).any(|window| { + window + == [ + "--ro-bind", + canonical_sandbox_cwd.as_str(), + canonical_sandbox_cwd.as_str(), + ] + })); assert!( !args .args .windows(2) .any(|window| { window == ["--chdir", link_command_cwd.as_str()] }) ); + assert!(!args.args.windows(3).any(|window| { + window + == [ + "--ro-bind", + link_sandbox_cwd.as_str(), + link_sandbox_cwd.as_str(), + ] + })); + } + + #[cfg(unix)] + #[test] + fn symlinked_writable_roots_bind_real_target_and_remap_carveouts() { + let temp_dir = TempDir::new().expect("temp dir"); + let real_root = temp_dir.path().join("real"); + let link_root = temp_dir.path().join("link"); + let blocked = real_root.join("blocked"); + std::fs::create_dir_all(&blocked).expect("create blocked dir"); + std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); + + let link_root = + AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); + let link_blocked = link_root.join("blocked"); + let real_root_str = path_to_string(&real_root); + let real_blocked_str = path_to_string(&blocked); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link_root }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link_blocked }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + + assert!(args.args.windows(3).any(|window| { + window == ["--bind", real_root_str.as_str(), real_root_str.as_str()] + })); + assert!(args.args.windows(6).any(|window| { + window + == [ + "--perms", + "000", + "--tmpfs", + real_blocked_str.as_str(), + "--remount-ro", + real_blocked_str.as_str(), + ] + })); + } + + #[cfg(unix)] + #[test] + fn writable_roots_under_symlinked_ancestors_bind_real_target() { + let temp_dir = TempDir::new().expect("temp dir"); + let logical_home = temp_dir.path().join("home"); + let real_codex = temp_dir.path().join("real-codex"); + let logical_codex = logical_home.join(".codex"); + let real_memories = real_codex.join("memories"); + let logical_memories = logical_codex.join("memories"); + std::fs::create_dir_all(&logical_home).expect("create logical home"); + std::fs::create_dir_all(&real_memories).expect("create memories dir"); + std::os::unix::fs::symlink(&real_codex, &logical_codex) + .expect("create symlinked codex home"); + + let logical_memories_root = + AbsolutePathBuf::from_absolute_path(&logical_memories).expect("absolute memories"); + let real_memories_str = path_to_string(&real_memories); + let logical_memories_str = path_to_string(&logical_memories); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: logical_memories_root, + }, + access: FileSystemAccessMode::Write, + }]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + + assert!(args.args.windows(3).any(|window| { + window + == [ + "--bind", + real_memories_str.as_str(), + real_memories_str.as_str(), + ] + })); + assert!(!args.args.windows(3).any(|window| { + window + == [ + "--bind", + logical_memories_str.as_str(), + logical_memories_str.as_str(), + ] + })); + } + + #[cfg(unix)] + #[test] + fn protected_symlinked_directory_subpaths_bind_target_read_only() { + let temp_dir = TempDir::new().expect("temp dir"); + let root = temp_dir.path().join("root"); + let agents_target = root.join("agents-target"); + let agents_link = root.join(".agents"); + std::fs::create_dir_all(&agents_target).expect("create agents target"); + std::os::unix::fs::symlink(&agents_target, &agents_link).expect("create symlinked .agents"); + + let root = AbsolutePathBuf::from_absolute_path(&root).expect("absolute root"); + let agents_link_str = path_to_string(&agents_link); + let agents_target_str = path_to_string(&agents_target); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { path: root }, + access: FileSystemAccessMode::Write, + }]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + + assert!(args.args.windows(3).any(|window| { + window + == [ + "--ro-bind", + agents_target_str.as_str(), + agents_target_str.as_str(), + ] + })); + assert!(!args.args.iter().any(|arg| arg == agents_link_str.as_str())); + } + + #[cfg(unix)] + #[test] + fn symlinked_writable_roots_mask_nested_symlink_escape_paths_without_binding_targets() { + let temp_dir = TempDir::new().expect("temp dir"); + let real_root = temp_dir.path().join("real"); + let link_root = temp_dir.path().join("link"); + let outside = temp_dir.path().join("outside-private"); + let linked_private = real_root.join("linked-private"); + std::fs::create_dir_all(&real_root).expect("create real root"); + std::fs::create_dir_all(&outside).expect("create outside dir"); + std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); + std::os::unix::fs::symlink(&outside, &linked_private) + .expect("create nested escape symlink"); + + let link_root = + AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); + let link_private = link_root.join("linked-private"); + let real_linked_private_str = path_to_string(&linked_private); + let outside_str = path_to_string(&outside); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link_root }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link_private }, + access: FileSystemAccessMode::None, + }, + ]); + + let args = create_filesystem_args(&policy, temp_dir.path()).expect("filesystem args"); + + assert!(args.args.windows(6).any(|window| { + window + == [ + "--perms", + "000", + "--tmpfs", + outside_str.as_str(), + "--remount-ro", + outside_str.as_str(), + ] + })); + assert!( + !args + .args + .iter() + .any(|arg| arg == real_linked_private_str.as_str()) + ); } #[test] diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 88f40ed72c..27064b6831 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -128,7 +128,6 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), chatgpt_user_id: Some("user-12345".to_string()), chatgpt_account_id: None, - is_org_owner: None, raw_jwt: fake_jwt, }, access_token: "test-access-token".to_string(), diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 5f2d5ab88d..71857c9700 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -302,14 +302,6 @@ impl CodexAuth { .and_then(|t| t.id_token.chatgpt_user_id) } - /// Returns `Some(true)` when the current ChatGPT token marks the user as a - /// workspace owner, `Some(false)` for non-owners, and `None` when auth is - /// unavailable or the token omits the claim. - pub fn is_workspace_owner(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.is_org_owner) - } - /// Account-facing plan classification derived from the current token. /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) /// mapped from the ID token's internal plan value. Prefer this when you diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index fd502ccec4..f70696d5a2 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -36,8 +36,6 @@ pub struct IdTokenInfo { pub chatgpt_user_id: Option, /// Organization/workspace identifier associated with the token, if present. pub chatgpt_account_id: Option, - /// Whether the current user owns the workspace, when present in the token. - pub is_org_owner: Option, pub raw_jwt: String, } @@ -90,8 +88,6 @@ struct AuthClaims { user_id: Option, #[serde(default)] chatgpt_account_id: Option, - #[serde(default)] - is_org_owner: Option, } #[derive(Deserialize)] @@ -143,7 +139,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result Ok(IdTokenInfo { email, @@ -151,7 +146,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result, /// Turn ID that this assessment belongs to. /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] @@ -176,6 +186,10 @@ pub struct GuardianAssessmentEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub rationale: Option, + /// Source that produced the terminal assessment decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub decision_source: Option, /// Canonical action payload that was reviewed. pub action: GuardianAssessmentAction, } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 5d08a50d4d..0b1d189727 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -35,7 +35,6 @@ fn rate_limit_snapshot() -> RateLimitSnapshot { resets_at: Some(secondary_reset_at), }), credits: None, - spend_control: None, plan_type: None, } } diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index 416d291fd1..f101f8bef0 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::path::PathBuf; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::canonicalize_preserving_symlinks; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -471,6 +472,9 @@ impl FileSystemSandboxPolicy { // so root-wide aliases do not create duplicate top-level masks. // Example: keep `/var/...` normalized under `/` instead of // materializing both `/var/...` and `/private/var/...`. + // Nested symlink paths under a writable root stay logical so + // downstream sandboxes can still bind the real target while + // masking the user-visible symlink inode when needed. let preserve_raw_carveout_paths = root.as_path().parent().is_some(); let raw_writable_roots: Vec<&AbsolutePathBuf> = writable_entries .iter() @@ -1081,14 +1085,17 @@ fn dedup_absolute_paths( fn normalize_effective_absolute_path(path: AbsolutePathBuf) -> AbsolutePathBuf { let raw_path = path.to_path_buf(); for ancestor in raw_path.ancestors() { - let Ok(canonical_ancestor) = ancestor.canonicalize() else { + if std::fs::symlink_metadata(ancestor).is_err() { + continue; + } + let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else { continue; }; let Ok(suffix) = raw_path.strip_prefix(ancestor) else { continue; }; if let Ok(normalized_path) = - AbsolutePathBuf::from_absolute_path(canonical_ancestor.join(suffix)) + AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix)) { return normalized_path; } @@ -1422,7 +1429,7 @@ mod tests { #[cfg(unix)] #[test] - fn effective_runtime_roots_canonicalize_symlinked_paths() { + fn effective_runtime_roots_preserve_symlinked_paths() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); @@ -1436,18 +1443,9 @@ mod tests { let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_blocked = link_root.join("blocked"); - let expected_root = AbsolutePathBuf::from_absolute_path( - real_root.canonicalize().expect("canonicalize real root"), - ) - .expect("absolute canonical root"); - let expected_blocked = AbsolutePathBuf::from_absolute_path( - blocked.canonicalize().expect("canonicalize blocked"), - ) - .expect("absolute canonical blocked"); - let expected_codex = AbsolutePathBuf::from_absolute_path( - codex_dir.canonicalize().expect("canonicalize .codex"), - ) - .expect("absolute canonical .codex"); + let expected_root = link_root.clone(); + let expected_blocked = link_blocked.clone(); + let expected_codex = link_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -1482,7 +1480,7 @@ mod tests { #[cfg(unix)] #[test] - fn current_working_directory_special_path_canonicalizes_symlinked_cwd() { + fn current_working_directory_special_path_preserves_symlinked_cwd() { let cwd = TempDir::new().expect("tempdir"); let real_root = cwd.path().join("real"); let link_root = cwd.path().join("link"); @@ -1497,22 +1495,11 @@ mod tests { let link_blocked = AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked"); - let expected_root = AbsolutePathBuf::from_absolute_path( - real_root.canonicalize().expect("canonicalize real root"), - ) - .expect("absolute canonical root"); - let expected_blocked = AbsolutePathBuf::from_absolute_path( - blocked.canonicalize().expect("canonicalize blocked"), - ) - .expect("absolute canonical blocked"); - let expected_agents = AbsolutePathBuf::from_absolute_path( - agents_dir.canonicalize().expect("canonicalize .agents"), - ) - .expect("absolute canonical .agents"); - let expected_codex = AbsolutePathBuf::from_absolute_path( - codex_dir.canonicalize().expect("canonicalize .codex"), - ) - .expect("absolute canonical .codex"); + let expected_root = + AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); + let expected_blocked = link_blocked.clone(); + let expected_agents = expected_root.join(".agents"); + let expected_codex = expected_root.join(".codex"); let policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -1617,11 +1604,8 @@ mod tests { let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_private = link_root.join("linked-private"); - let expected_root = AbsolutePathBuf::from_absolute_path( - real_root.canonicalize().expect("canonicalize real root"), - ) - .expect("absolute canonical root"); - let expected_linked_private = expected_root.join("linked-private"); + let expected_root = link_root.clone(); + let expected_linked_private = link_private.clone(); let unexpected_decoy = AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy")) .expect("absolute canonical decoy"); @@ -1667,11 +1651,8 @@ mod tests { let link_root = AbsolutePathBuf::from_absolute_path(&link_root).expect("absolute symlinked root"); let link_private = link_root.join("linked-private"); - let expected_root = AbsolutePathBuf::from_absolute_path( - real_root.canonicalize().expect("canonicalize real root"), - ) - .expect("absolute canonical root"); - let expected_linked_private = expected_root.join("linked-private"); + let expected_root = link_root.clone(); + let expected_linked_private = link_private.clone(); let unexpected_decoy = AbsolutePathBuf::from_absolute_path(decoy.canonicalize().expect("canonicalize decoy")) .expect("absolute canonical decoy"); @@ -1737,12 +1718,12 @@ mod tests { #[cfg(unix)] #[test] - fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() { + fn tmpdir_special_path_preserves_symlinked_tmpdir() { if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() { let output = std::process::Command::new(std::env::current_exe().expect("test binary")) .env(SYMLINKED_TMPDIR_TEST_ENV, "1") .arg("--exact") - .arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir") + .arg("permissions::tests::tmpdir_special_path_preserves_symlinked_tmpdir") .output() .expect("run tmpdir subprocess test"); @@ -1767,20 +1748,10 @@ mod tests { let link_blocked = AbsolutePathBuf::from_absolute_path(link_tmpdir.join("blocked")).expect("link blocked"); - let expected_root = AbsolutePathBuf::from_absolute_path( - real_tmpdir - .canonicalize() - .expect("canonicalize real tmpdir"), - ) - .expect("absolute canonical tmpdir"); - let expected_blocked = AbsolutePathBuf::from_absolute_path( - blocked.canonicalize().expect("canonicalize blocked"), - ) - .expect("absolute canonical blocked"); - let expected_codex = AbsolutePathBuf::from_absolute_path( - codex_dir.canonicalize().expect("canonicalize .codex"), - ) - .expect("absolute canonical .codex"); + let expected_root = + AbsolutePathBuf::from_absolute_path(&link_tmpdir).expect("absolute symlinked tmpdir"); + let expected_blocked = link_blocked.clone(); + let expected_codex = expected_root.join(".codex"); unsafe { std::env::set_var("TMPDIR", &link_tmpdir); @@ -1932,8 +1903,7 @@ mod tests { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let expected_docs = AbsolutePathBuf::from_absolute_path( - cwd.path() - .canonicalize() + canonicalize_preserving_symlinks(cwd.path()) .expect("canonicalize cwd") .join("docs"), ) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a5fdb9485a..121e59a270 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -65,6 +65,7 @@ pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; +pub use crate::approvals::GuardianAssessmentDecisionSource; pub use crate::approvals::GuardianAssessmentEvent; pub use crate::approvals::GuardianAssessmentStatus; pub use crate::approvals::GuardianCommandSource; @@ -647,9 +648,6 @@ pub enum Op { /// Request a code review from the agent. Review { review_request: ReviewRequest }, - /// Ask the workspace owner to add Codex credits to the current ChatGPT workspace. - SendAddCreditsNudgeEmail, - /// Request to shut down codex instance. Shutdown, @@ -760,7 +758,6 @@ impl Op { Self::Undo => "undo", Self::ThreadRollback { .. } => "thread_rollback", Self::Review { .. } => "review", - Self::SendAddCreditsNudgeEmail => "send_add_credits_nudge_email", Self::Shutdown => "shutdown", Self::RunUserShellCommand { .. } => "run_user_shell_command", Self::ListModels => "list_models", @@ -1519,9 +1516,6 @@ pub enum EventMsg { /// List of skills available to the agent. ListSkillsResponse(ListSkillsResponseEvent), - /// Response to SendAddCreditsNudgeEmail. - AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent), - /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), @@ -1575,18 +1569,6 @@ pub enum EventMsg { CollabResumeEnd(CollabResumeEndEvent), } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -pub struct AddCreditsNudgeEmailResponseEvent { - pub result: Result, -} - #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { @@ -2154,7 +2136,6 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, - pub spend_control: Option, pub plan_type: Option, } @@ -2177,11 +2158,6 @@ pub struct CreditsSnapshot { pub balance: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] -pub struct SpendControlSnapshot { - pub reached: bool, -} - // Includes prompts, tools and space to call compact. const BASELINE_TOKENS: i64 = 12000; @@ -2449,6 +2425,7 @@ pub struct ResumedHistory { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub enum InitialHistory { New, + Cleared, Resumed(ResumedHistory), Forked(Vec), } @@ -2456,7 +2433,7 @@ pub enum InitialHistory { impl InitialHistory { pub fn forked_from_id(&self) -> Option { match self { - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, InitialHistory::Resumed(resumed) => { resumed.history.iter().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id, @@ -2472,7 +2449,7 @@ impl InitialHistory { pub fn session_cwd(&self) -> Option { match self { - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, InitialHistory::Resumed(resumed) => session_cwd_from_items(&resumed.history), InitialHistory::Forked(items) => session_cwd_from_items(items), } @@ -2480,7 +2457,7 @@ impl InitialHistory { pub fn get_rollout_items(&self) -> Vec { match self { - InitialHistory::New => Vec::new(), + InitialHistory::New | InitialHistory::Cleared => Vec::new(), InitialHistory::Resumed(resumed) => resumed.history.clone(), InitialHistory::Forked(items) => items.clone(), } @@ -2488,7 +2465,7 @@ impl InitialHistory { pub fn get_event_msgs(&self) -> Option> { match self { - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, InitialHistory::Resumed(resumed) => Some( resumed .history @@ -2514,7 +2491,7 @@ impl InitialHistory { pub fn get_base_instructions(&self) -> Option { // TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item? match self { - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, InitialHistory::Resumed(resumed) => { resumed.history.iter().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), @@ -2530,7 +2507,7 @@ impl InitialHistory { pub fn get_dynamic_tools(&self) -> Option> { match self { - InitialHistory::New => None, + InitialHistory::New | InitialHistory::Cleared => None, InitialHistory::Resumed(resumed) => { resumed.history.iter().find_map(|item| match item { RolloutItem::SessionMeta(meta_line) => meta_line.meta.dynamic_tools.clone(), @@ -4174,7 +4151,8 @@ mod tests { #[test] fn restricted_file_system_policy_treats_root_with_carveouts_as_scoped_access() { let cwd = TempDir::new().expect("tempdir"); - let canonical_cwd = cwd.path().canonicalize().expect("canonicalize cwd"); + let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) + .expect("canonicalize cwd"); let root = AbsolutePathBuf::from_absolute_path(&canonical_cwd) .expect("absolute canonical tempdir") .as_path() @@ -4184,8 +4162,7 @@ mod tests { .expect("filesystem root"); let blocked = AbsolutePathBuf::resolve_path_against_base("blocked", cwd.path()); let expected_blocked = AbsolutePathBuf::from_absolute_path( - cwd.path() - .canonicalize() + codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) .expect("canonicalize cwd") .join("blocked"), ) @@ -4230,7 +4207,8 @@ mod tests { let cwd = TempDir::new().expect("tempdir"); std::fs::create_dir_all(cwd.path().join(".agents")).expect("create .agents"); std::fs::create_dir_all(cwd.path().join(".codex")).expect("create .codex"); - let canonical_cwd = cwd.path().canonicalize().expect("canonicalize cwd"); + let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) + .expect("canonicalize cwd"); let cwd_absolute = AbsolutePathBuf::from_absolute_path(&canonical_cwd).expect("absolute tempdir"); let secret = AbsolutePathBuf::resolve_path_against_base("secret", cwd.path()); @@ -4297,7 +4275,8 @@ mod tests { #[test] fn restricted_file_system_policy_treats_read_entries_as_read_only_subpaths() { let cwd = TempDir::new().expect("tempdir"); - let canonical_cwd = cwd.path().canonicalize().expect("canonicalize cwd"); + let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) + .expect("canonicalize cwd"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let docs_public = AbsolutePathBuf::resolve_path_against_base("docs/public", cwd.path()); let expected_docs = AbsolutePathBuf::from_absolute_path(canonical_cwd.join("docs")) @@ -4344,7 +4323,8 @@ mod tests { fn legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = TempDir::new().expect("tempdir"); let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); - let canonical_cwd = cwd.path().canonicalize().expect("canonicalize cwd"); + let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) + .expect("canonicalize cwd"); let expected_dot_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex")) .expect("canonical .codex"); let policy = SandboxPolicy::WorkspaceWrite { diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 34c2612add..4b50781e76 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -164,7 +164,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListSkillsResponse(_) - | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::DeprecationNotice(_) diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 25c79fcc9e..73ecf81c9b 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -11,7 +11,7 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize; +use codex_utils_absolute_path::canonicalize_preserving_symlinks; use std::collections::HashSet; #[derive(Debug, Clone, PartialEq, Eq)] @@ -171,7 +171,7 @@ fn normalize_permission_paths( let mut seen = HashSet::new(); for path in paths { - let canonicalized = canonicalize(path.as_path()) + let canonicalized = canonicalize_preserving_symlinks(path.as_path()) .ok() .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) .unwrap_or(path); diff --git a/codex-rs/sandboxing/src/policy_transforms_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs index 10f49d95f3..f33d312f15 100644 --- a/codex-rs/sandboxing/src/policy_transforms_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -130,7 +130,7 @@ fn normalize_additional_permissions_preserves_network() { #[cfg(unix)] #[test] -fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() { +fn normalize_additional_permissions_preserves_symlinked_write_paths() { let temp_dir = TempDir::new().expect("create temp dir"); let real_root = temp_dir.path().join("real"); let link_root = temp_dir.path().join("link"); @@ -140,11 +140,6 @@ fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() { let link_write_dir = AbsolutePathBuf::from_absolute_path(link_root.join("write")).expect("link write dir"); - let expected_write_dir = AbsolutePathBuf::from_absolute_path( - write_dir.canonicalize().expect("canonicalize write dir"), - ) - .expect("absolute canonical write dir"); - let permissions = normalize_additional_permissions(PermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), @@ -158,7 +153,10 @@ fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() { permissions.file_system, Some(FileSystemPermissions { read: Some(vec![]), - write: Some(vec![expected_write_dir]), + write: Some(vec![ + AbsolutePathBuf::from_absolute_path(link_root.join("write")) + .expect("link write dir") + ]), }) ); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c7c9e79c76..3791039f35 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -82,6 +82,7 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; @@ -537,9 +538,6 @@ impl ThreadEventStore { ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_)) | ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_)) - | ThreadBufferedEvent::Notification( - ServerNotification::AddCreditsNudgeEmailCompleted(_), - ) | ThreadBufferedEvent::FeedbackSubmission(_) ) } @@ -1099,8 +1097,6 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, status_account_display: self.chat_widget.status_account_display().cloned(), - initial_workspace_role: self.chat_widget.current_workspace_role(), - initial_is_workspace_owner: self.chat_widget.current_is_workspace_owner(), initial_plan_type: self.chat_widget.current_plan_type(), model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, @@ -2431,10 +2427,6 @@ impl App { .await?; Ok(true) } - AppCommandView::SendAddCreditsNudgeEmail => { - app_server.thread_add_credits_nudge_email(thread_id).await?; - Ok(true) - } AppCommandView::ReloadUserConfig => { app_server.reload_user_config().await?; Ok(true) @@ -3310,6 +3302,7 @@ impl App { &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, + session_start_source: Option, ) { // Start a fresh in-memory session while preserving resumability via persisted rollout // history. @@ -3331,7 +3324,10 @@ impl App { } } self.config = config.clone(); - match app_server.start_thread(&config).await { + match app_server + .start_thread_with_session_start_source(&config, session_start_source) + .await + { Ok(started) => { if let Err(err) = self .replace_chat_widget_with_app_server_thread(tui, app_server, started) @@ -3658,8 +3654,6 @@ impl App { let has_chatgpt_account = bootstrap.has_chatgpt_account; let requires_openai_auth = bootstrap.requires_openai_auth; let status_account_display = bootstrap.status_account_display.clone(); - let initial_workspace_role = bootstrap.workspace_role; - let initial_is_workspace_owner = bootstrap.is_workspace_owner; let initial_plan_type = bootstrap.plan_type; let session_telemetry = SessionTelemetry::new( ThreadId::new(), @@ -3709,8 +3703,6 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), - initial_workspace_role, - initial_is_workspace_owner, initial_plan_type, model: Some(model.clone()), startup_tooltip_override, @@ -3745,8 +3737,6 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), - initial_workspace_role, - initial_is_workspace_owner, initial_plan_type, model: config.model.clone(), startup_tooltip_override: None, @@ -3786,8 +3776,6 @@ impl App { feedback: feedback.clone(), is_first_run, status_account_display: status_account_display.clone(), - initial_workspace_role, - initial_is_workspace_owner, initial_plan_type, model: config.model.clone(), startup_tooltip_override: None, @@ -4175,15 +4163,21 @@ impl App { ) -> Result { match event { AppEvent::NewSession => { - self.start_fresh_session_with_summary_hint(tui, app_server) - .await; + self.start_fresh_session_with_summary_hint( + tui, app_server, /*session_start_source*/ None, + ) + .await; } AppEvent::ClearUi => { self.clear_terminal_ui(tui, /*redraw_header*/ false)?; self.reset_app_ui_state_after_clear(); - self.start_fresh_session_with_summary_hint(tui, app_server) - .await; + self.start_fresh_session_with_summary_hint( + tui, + app_server, + Some(ThreadStartSource::Clear), + ) + .await; } AppEvent::OpenResumePicker => { let picker_app_server = match crate::start_app_server_for_picker( @@ -4566,23 +4560,6 @@ impl App { } } }, - AppEvent::NotifyWorkspaceOwner => { - if self.active_thread_id.is_some() { - self.chat_widget.start_notify_workspace_owner(); - if let Err(err) = self - .submit_active_thread_op(app_server, Op::SendAddCreditsNudgeEmail.into()) - .await - { - self.chat_widget - .finish_notify_workspace_owner(Err(err.to_string())); - return Err(err); - } - } else { - self.chat_widget.finish_notify_workspace_owner(Err( - "no active thread is available".to_string(), - )); - } - } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); } @@ -6363,8 +6340,6 @@ mod tests { use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; - use codex_app_server_protocol::AddCreditsNudgeEmailNotification; - use codex_app_server_protocol::AddCreditsNudgeEmailResult; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -6752,8 +6727,6 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), is_first_run: false, status_account_display: None, - initial_workspace_role: None, - initial_is_workspace_owner: None, initial_plan_type: None, model: Some(model), startup_tooltip_override: None, @@ -9657,29 +9630,6 @@ guardian_approval = true ); } - #[test] - fn thread_event_store_rebase_preserves_add_credits_nudge_email_completion() { - let thread_id = ThreadId::new(); - let mut store = ThreadEventStore::new(/*capacity*/ 8); - let notification = - ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification { - thread_id: thread_id.to_string(), - result: AddCreditsNudgeEmailResult::Sent, - }); - store.push_notification(notification.clone()); - - store.rebase_buffer_after_session_refresh(); - - let snapshot = store.snapshot(); - let [ThreadBufferedEvent::Notification(actual)] = snapshot.events.as_slice() else { - panic!("expected add-credits nudge email completion notification"); - }; - assert_eq!( - serde_json::to_value(actual).expect("notification should serialize"), - serde_json::to_value(notification).expect("notification should serialize"), - ); - } - #[test] fn build_feedback_upload_params_includes_thread_id_and_rollout_path() { let thread_id = ThreadId::new(); @@ -10741,8 +10691,6 @@ guardian_approval = true feedback: app.feedback.clone(), is_first_run: false, status_account_display: app.chat_widget.status_account_display().cloned(), - initial_workspace_role: app.chat_widget.current_workspace_role(), - initial_is_workspace_owner: app.chat_widget.current_is_workspace_owner(), initial_plan_type: app.chat_widget.current_plan_type(), model: Some(app.chat_widget.current_model().to_string()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 5df4c18e54..0bd78f353a 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -176,8 +176,6 @@ impl App { notification.auth_mode, notification.plan_type, ), - notification.workspace_role, - notification.is_workspace_owner, notification.plan_type, matches!( notification.auth_mode, @@ -402,9 +400,6 @@ fn server_notification_thread_target( ServerNotification::ThreadRealtimeClosed(notification) => { Some(notification.thread_id.as_str()) } - ServerNotification::AddCreditsNudgeEmailCompleted(notification) => { - Some(notification.thread_id.as_str()) - } ServerNotification::SkillsChanged(_) | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) @@ -1032,14 +1027,10 @@ fn app_server_codex_error_info_to_core( #[cfg(test)] mod tests { - use super::ServerNotificationThreadTarget; use super::command_execution_started_event; use super::server_notification_thread_events; - use super::server_notification_thread_target; use super::thread_snapshot_events; use super::turn_snapshot_events; - use codex_app_server_protocol::AddCreditsNudgeEmailNotification; - use codex_app_server_protocol::AddCreditsNudgeEmailResult; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::CommandAction; @@ -1070,19 +1061,6 @@ mod tests { use pretty_assertions::assert_eq; use std::path::PathBuf; - #[test] - fn add_credits_nudge_email_completion_targets_thread() { - let thread_id = ThreadId::new(); - let target = server_notification_thread_target( - &ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification { - thread_id: thread_id.to_string(), - result: AddCreditsNudgeEmailResult::Sent, - }), - ); - - assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); - } - #[test] fn bridges_completed_agent_messages_from_server_notifications() { let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 14e3c2dca7..0646cc297d 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -98,7 +98,6 @@ pub(crate) enum AppCommandView<'a> { SetThreadName { name: &'a str, }, - SendAddCreditsNudgeEmail, Shutdown, ThreadRollback { num_turns: u32, @@ -392,7 +391,6 @@ impl AppCommand { num_turns: *num_turns, }, Op::Review { review_request } => AppCommandView::Review { review_request }, - Op::SendAddCreditsNudgeEmail => AppCommandView::SendAddCreditsNudgeEmail, op => AppCommandView::Other(op), } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bab9af27fa..78a2525350 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -170,10 +170,6 @@ pub(crate) enum AppEvent { result: Result, String>, }, - /// Ask Codex to notify the current workspace owner that credits need to be - /// added. - NotifyWorkspaceOwner, - /// Result of prefetching connectors. ConnectorsLoaded { result: Result, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 3bfbc8404e..f1cabe2844 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -24,8 +24,6 @@ use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams; -use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; @@ -57,6 +55,7 @@ use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::Turn; @@ -104,8 +103,6 @@ pub(crate) struct AppServerBootstrap { pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, - pub(crate) workspace_role: Option, - pub(crate) is_workspace_owner: Option, pub(crate) plan_type: Option, /// Whether the configured model provider needs OpenAI-style auth. Combined /// with `has_chatgpt_account` to decide if a startup rate-limit prefetch @@ -220,8 +217,6 @@ impl AppServerSession { account_email, auth_mode, status_account_display, - workspace_role, - is_workspace_owner, plan_type, feedback_audience, has_chatgpt_account, @@ -231,8 +226,6 @@ impl AppServerSession { Some(TelemetryAuthMode::ApiKey), Some(StatusAccountDisplay::ApiKey), None, - None, - None, FeedbackAudience::External, false, ), @@ -249,30 +242,17 @@ impl AppServerSession { email: Some(email), plan: Some(plan_type_display_name(plan_type)), }), - account.workspace_role, - account.is_workspace_owner, Some(plan_type), feedback_audience, true, ) } - None => ( - None, - None, - None, - None, - None, - None, - FeedbackAudience::External, - false, - ), + None => (None, None, None, None, FeedbackAudience::External, false), }; Ok(AppServerBootstrap { account_email, auth_mode, status_account_display, - workspace_role, - is_workspace_owner, plan_type, requires_openai_auth: account.requires_openai_auth, default_model, @@ -304,6 +284,15 @@ impl AppServerSession { } pub(crate) async fn start_thread(&mut self, config: &Config) -> Result { + self.start_thread_with_session_start_source(config, /*session_start_source*/ None) + .await + } + + pub(crate) async fn start_thread_with_session_start_source( + &mut self, + config: &Config, + session_start_source: Option, + ) -> Result { let request_id = self.next_request_id(); let response: ThreadStartResponse = self .client @@ -313,6 +302,7 @@ impl AppServerSession { config, self.thread_params_mode(), self.remote_cwd_override.as_deref(), + session_start_source, ), }) .await @@ -570,24 +560,6 @@ impl AppServerSession { Ok(()) } - pub(crate) async fn thread_add_credits_nudge_email( - &mut self, - thread_id: ThreadId, - ) -> Result<()> { - let request_id = self.next_request_id(); - let _: ThreadAddCreditsNudgeEmailResponse = self - .client - .request_typed(ClientRequest::ThreadAddCreditsNudgeEmail { - request_id, - params: ThreadAddCreditsNudgeEmailParams { - thread_id: thread_id.to_string(), - }, - }) - .await - .wrap_err("thread/addCreditsNudgeEmail failed in TUI")?; - Ok(()) - } - pub(crate) async fn thread_background_terminals_clean( &mut self, thread_id: ThreadId, @@ -895,6 +867,7 @@ fn thread_start_params_from_config( config: &Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, + session_start_source: Option, ) -> ThreadStartParams { ThreadStartParams { model: config.model.clone(), @@ -905,6 +878,7 @@ fn thread_start_params_from_config( sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), + session_start_source, persist_extended_history: true, ..ThreadStartParams::default() } @@ -1160,9 +1134,6 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core( primary: snapshot.primary.map(app_server_rate_limit_window_to_core), secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core), credits: snapshot.credits.map(app_server_credits_snapshot_to_core), - spend_control: snapshot - .spend_control - .map(app_server_spend_control_snapshot_to_core), plan_type: snapshot.plan_type, } } @@ -1187,14 +1158,6 @@ fn app_server_credits_snapshot_to_core( } } -fn app_server_spend_control_snapshot_to_core( - snapshot: codex_app_server_protocol::SpendControlSnapshot, -) -> codex_protocol::protocol::SpendControlSnapshot { - codex_protocol::protocol::SpendControlSnapshot { - reached: snapshot.reached, - } -} - #[cfg(test)] mod tests { use super::*; @@ -1222,12 +1185,28 @@ mod tests { &config, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, + /*session_start_source*/ None, ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!(params.model_provider, Some(config.model_provider_id)); } + #[tokio::test] + async fn thread_start_params_can_mark_clear_source() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + + let params = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + Some(ThreadStartSource::Clear), + ); + + assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear)); + } + #[tokio::test] async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -1238,6 +1217,7 @@ mod tests { &config, ThreadParamsMode::Remote, /*remote_cwd_override*/ None, + /*session_start_source*/ None, ); let resume = thread_resume_params_from_config( config.clone(), @@ -1271,6 +1251,7 @@ mod tests { &config, ThreadParamsMode::Remote, Some(remote_cwd.as_path()), + /*session_start_source*/ None, ); let resume = thread_resume_params_from_config( config.clone(), diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index efaa124d0d..e3eecf2ed0 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -648,23 +648,7 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { - if let Some((visible_idx, _)) = self - .filtered_indices - .iter() - .enumerate() - .find(|(_, actual_idx)| { - self.items.get(**actual_idx).is_some_and(|item| { - item.display_shortcut - .as_ref() - .is_some_and(|shortcut| shortcut.is_press(key_event)) - && item.disabled_reason.is_none() - && !item.is_disabled - }) - }) - { - self.state.selected_idx = Some(visible_idx); - self.accept(); - } else if let Some(idx) = c + if let Some(idx) = c .to_digit(10) .map(|d| d as usize) .and_then(|d| d.checked_sub(1)) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 47733c7258..5bf1eea305 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -67,7 +67,6 @@ use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; use crate::text_formatting::proper_join; use crate::version::CODEX_CLI_VERSION; -use codex_app_server_protocol::AddCreditsNudgeEmailResult as AppServerAddCreditsNudgeEmailResult; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; @@ -92,7 +91,6 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::WorkspaceRole as AppServerWorkspaceRole; use codex_chatgpt::connectors; use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; @@ -135,7 +133,6 @@ use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; -use codex_protocol::protocol::AddCreditsNudgeEmailStatus; #[cfg(test)] use codex_protocol::protocol::AgentMessageDeltaEvent; #[cfg(test)] @@ -174,6 +171,7 @@ use codex_protocol::protocol::ExecCommandSource; #[cfg(test)] use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::GuardianAssessmentAction; +use codex_protocol::protocol::GuardianAssessmentDecisionSource; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::ImageGenerationBeginEvent; @@ -194,7 +192,6 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; -use codex_protocol::protocol::SpendControlSnapshot; #[cfg(test)] use codex_protocol::protocol::StreamErrorEvent; use codex_protocol::protocol::TerminalInteractionEvent; @@ -560,8 +557,6 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) status_account_display: Option, - pub(crate) initial_workspace_role: Option, - pub(crate) initial_is_workspace_owner: Option, pub(crate) initial_plan_type: Option, pub(crate) model: Option, pub(crate) startup_tooltip_override: Option, @@ -608,32 +603,6 @@ enum RateLimitErrorKind { Generic, } -pub(crate) const CODEX_USAGE_SETTINGS_URL: &str = "https://chatgpt.com/codex/settings/usage"; -const WORKSPACE_OWNER_NOTIFICATION_PROMPT: &str = - "Your workspace is out of credits. Request more from your workspace owner? [y/N]"; -const WORKSPACE_OWNER_NOTIFICATION_TITLE: &str = "Request more credits from your workspace owner?"; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum UsageBasedWorkspaceRateLimitState { - OwnerCreditsDepleted, - OwnerSpendCapReached, - MemberCreditsDepleted, - MemberSpendCapReached, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum UsageBasedWorkspaceBlockKind { - CreditsDepleted, - SpendCapReached, -} - -fn is_usage_based_workspace_plan(plan_type: PlanType) -> bool { - matches!( - plan_type, - PlanType::SelfServeBusinessUsageBased | PlanType::EnterpriseCbpUsageBased - ) -} - #[cfg(test)] fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option { match info { @@ -789,8 +758,6 @@ pub(crate) struct ChatWidget { /// The currently active collaboration mask, if any. active_collaboration_mask: Option, has_chatgpt_account: bool, - workspace_role: Option, - is_workspace_owner: Option, model_catalog: Arc, session_telemetry: SessionTelemetry, session_header: SessionHeader, @@ -801,8 +768,6 @@ pub(crate) struct ChatWidget { refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, plan_type: Option, - notify_workspace_owner_in_flight: bool, - pending_workspace_owner_notification_prompt: bool, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, adaptive_chunking: AdaptiveChunkingPolicy, @@ -2720,15 +2685,6 @@ impl ChatWidget { balance: credits.balance.clone(), }); } - if snapshot.spend_control.is_none() { - snapshot.spend_control = self - .rate_limit_snapshots_by_limit_id - .get(&limit_id) - .and_then(|display| display.spend_control.as_ref()) - .map(|spend_control| SpendControlSnapshot { - reached: spend_control.reached, - }); - } self.plan_type = snapshot.plan_type.or(self.plan_type); @@ -2797,7 +2753,6 @@ impl ChatWidget { } else { self.rate_limit_snapshots_by_limit_id.clear(); } - self.maybe_show_pending_workspace_owner_notification_prompt(); self.refresh_status_line(); } /// Finalize any active exec as failed and stop/clear agent-turn UI state. @@ -2840,34 +2795,15 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { - self.on_error_with_hint(message, /*hint*/ None); - } - - fn on_error_with_hint(&mut self, message: String, hint: Option) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - self.add_to_history(history_cell::new_error_event_with_hint(message, hint)); + self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } - fn on_rate_limit_error(&mut self, message: String) { - if self.should_prefetch_rate_limits() { - self.request_rate_limit_refresh(); - } - let guidance = self.usage_based_workspace_rate_limit_error_guidance(); - let should_prompt_workspace_owner = self.should_prompt_workspace_owner_notification(); - let hint = guidance.as_ref().and_then(|(_, hint)| hint.clone()); - let message = guidance.map(|(message, _)| message).unwrap_or(message); - self.on_error_with_hint(message, hint); - if should_prompt_workspace_owner { - self.pending_workspace_owner_notification_prompt = true; - self.maybe_show_pending_workspace_owner_notification_prompt(); - } - } - fn handle_non_retry_error( &mut self, message: String, @@ -2884,7 +2820,7 @@ impl ChatWidget { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_rate_limit_error(message); + self.on_error(message) } } } else { @@ -4777,8 +4713,6 @@ impl ChatWidget { feedback, is_first_run, status_account_display, - initial_workspace_role, - initial_is_workspace_owner, initial_plan_type, model, startup_tooltip_override, @@ -4840,8 +4774,6 @@ impl ChatWidget { current_collaboration_mode, active_collaboration_mask, has_chatgpt_account, - workspace_role: initial_workspace_role, - is_workspace_owner: initial_is_workspace_owner, model_catalog, session_telemetry, session_header: SessionHeader::new(header_model), @@ -4852,8 +4784,6 @@ impl ChatWidget { refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: initial_plan_type, - notify_workspace_owner_in_flight: false, - pending_workspace_owner_notification_prompt: false, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), adaptive_chunking: AdaptiveChunkingPolicy::default(), @@ -5494,7 +5424,9 @@ impl ChatWidget { } SlashCommand::Status => { if self.should_prefetch_rate_limits() { - let request_id = self.next_rate_limit_refresh_request_id(); + let request_id = self.next_status_refresh_request_id; + self.next_status_refresh_request_id = + self.next_status_refresh_request_id.wrapping_add(1); self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); self.app_event_tx.send(AppEvent::RefreshRateLimits { origin: RateLimitRefreshOrigin::StatusCommand { request_id }, @@ -6682,17 +6614,19 @@ impl ChatWidget { } ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { self.on_guardian_review_notification( - notification.target_item_id, + notification.review_id, notification.turn_id, notification.review, + /*decision_source*/ None, notification.action, ); } ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { self.on_guardian_review_notification( - notification.target_item_id, + notification.review_id, notification.turn_id, notification.review, + Some(notification.decision_source), notification.action, ); } @@ -6753,18 +6687,6 @@ impl ChatWidget { ); } } - ServerNotification::AddCreditsNudgeEmailCompleted(notification) => { - let result = match notification.result { - AppServerAddCreditsNudgeEmailResult::Sent => { - Ok(AddCreditsNudgeEmailStatus::Sent) - } - AppServerAddCreditsNudgeEmailResult::CooldownActive => { - Ok(AddCreditsNudgeEmailStatus::CooldownActive) - } - AppServerAddCreditsNudgeEmailResult::Failed { message } => Err(message), - }; - self.finish_notify_workspace_owner(result); - } ServerNotification::ThreadRealtimeSdp(notification) => { if !from_replay { self.on_realtime_conversation_sdp(notification.sdp); @@ -6975,10 +6897,12 @@ impl ChatWidget { id: String, turn_id: String, review: codex_app_server_protocol::GuardianApprovalReview, + decision_source: Option, action: GuardianApprovalReviewAction, ) { self.on_guardian_assessment(GuardianAssessmentEvent { id, + target_item_id: None, turn_id, status: match review.status { codex_app_server_protocol::GuardianApprovalReviewStatus::InProgress => { @@ -7025,6 +6949,11 @@ impl ChatWidget { } }), rationale: review.rationale, + decision_source: decision_source.map(|source| match source { + codex_app_server_protocol::AutoReviewDecisionSource::Agent => { + GuardianAssessmentDecisionSource::Agent + } + }), action: action.into(), }); } @@ -7173,7 +7102,7 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_rate_limit_error(message) + self.on_error(message) } } } else { @@ -7229,9 +7158,6 @@ impl ChatWidget { EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::AddCreditsNudgeEmailResponse(ev) => { - self.finish_notify_workspace_owner(ev.result); - } EventMsg::SkillsUpdateAvailable => { self.submit_op(AppCommand::list_skills( Vec::new(), @@ -9771,268 +9697,19 @@ impl ChatWidget { self.plan_type } - pub(crate) fn current_is_workspace_owner(&self) -> Option { - self.workspace_role - .map(|role| { - matches!( - role, - AppServerWorkspaceRole::AccountOwner | AppServerWorkspaceRole::AccountAdmin - ) - }) - .or(self.is_workspace_owner) - } - - pub(crate) fn current_workspace_role(&self) -> Option { - self.workspace_role - } - pub(crate) fn has_chatgpt_account(&self) -> bool { self.has_chatgpt_account } - fn usage_based_workspace_rate_limit_state(&self) -> Option { - let block_kind = self.usage_based_workspace_block_kind()?; - let is_workspace_owner = self.current_is_workspace_owner()?; - Some(match (is_workspace_owner, block_kind) { - (true, UsageBasedWorkspaceBlockKind::CreditsDepleted) => { - UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted - } - (true, UsageBasedWorkspaceBlockKind::SpendCapReached) => { - UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached - } - (false, UsageBasedWorkspaceBlockKind::CreditsDepleted) => { - UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted - } - (false, UsageBasedWorkspaceBlockKind::SpendCapReached) => { - UsageBasedWorkspaceRateLimitState::MemberSpendCapReached - } - }) - } - - fn usage_based_workspace_block_kind(&self) -> Option { - let plan_type = self.plan_type?; - if !is_usage_based_workspace_plan(plan_type) { - return None; - } - - let codex_snapshot = self.rate_limit_snapshots_by_limit_id.get("codex")?; - let credits_depleted = codex_snapshot - .credits - .as_ref() - .is_some_and(|credits| !credits.has_credits); - let spend_control_reached = codex_snapshot - .spend_control - .as_ref() - .map(|spend_control| spend_control.reached); - - let blocked_state = match (credits_depleted, spend_control_reached) { - (true, _) => Some(true), - (false, Some(true)) => Some(false), - (false, Some(false)) => None, - // Older snapshots do not carry spend-control state, so preserve the - // historical interpretation for compatibility. - (false, None) => Some(false), - }?; - - Some(match blocked_state { - true => UsageBasedWorkspaceBlockKind::CreditsDepleted, - false => UsageBasedWorkspaceBlockKind::SpendCapReached, - }) - } - - fn usage_based_workspace_rate_limit_error_guidance(&self) -> Option<(String, Option)> { - if let Some(state) = self.usage_based_workspace_rate_limit_state() { - return match state { - UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted => Some(( - "Your workspace is out of credits.".to_string(), - Some(format!( - "Visit {CODEX_USAGE_SETTINGS_URL} to add workspace credits and continue using Codex." - )), - )), - UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached => Some(( - "Your workspace has reached its spend cap.".to_string(), - Some(format!( - "Visit {CODEX_USAGE_SETTINGS_URL} to increase your workspace spend cap and continue using Codex." - )), - )), - UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted => { - Some((WORKSPACE_OWNER_NOTIFICATION_PROMPT.to_string(), None)) - } - UsageBasedWorkspaceRateLimitState::MemberSpendCapReached => Some(( - "Your workspace has reached its spend cap.".to_string(), - Some(format!( - "Ask an admin to increase your workspace spend cap. Visit {CODEX_USAGE_SETTINGS_URL} for usage settings." - )), - )), - }; - } - - match self.usage_based_workspace_block_kind()? { - UsageBasedWorkspaceBlockKind::CreditsDepleted => Some(( - "Your workspace is out of credits.".to_string(), - Some(format!( - "If you're the workspace owner, visit {CODEX_USAGE_SETTINGS_URL} to add credits. Otherwise wait a moment while Codex refreshes your workspace status." - )), - )), - UsageBasedWorkspaceBlockKind::SpendCapReached => Some(( - "Your workspace has reached its spend cap.".to_string(), - Some(format!( - "Ask an admin to increase the workspace spend cap, or visit {CODEX_USAGE_SETTINGS_URL} for usage settings." - )), - )), - } - } - - fn next_rate_limit_refresh_request_id(&mut self) -> u64 { - let request_id = self.next_status_refresh_request_id; - self.next_status_refresh_request_id = self.next_status_refresh_request_id.wrapping_add(1); - request_id - } - - fn send_rate_limit_refresh_request(&self, request_id: u64) { - self.app_event_tx.send(AppEvent::RefreshRateLimits { - origin: RateLimitRefreshOrigin::StatusCommand { request_id }, - }); - } - - fn request_rate_limit_refresh(&mut self) -> u64 { - let request_id = self.next_rate_limit_refresh_request_id(); - self.send_rate_limit_refresh_request(request_id); - request_id - } - - fn missing_usage_based_workspace_rate_limit_snapshot(&self) -> bool { - self.should_prefetch_rate_limits() - && self.plan_type.is_some() - && !self.rate_limit_snapshots_by_limit_id.contains_key("codex") - } - - fn should_prompt_workspace_owner_notification(&self) -> bool { - if self.notify_workspace_owner_in_flight { - return false; - } - - if matches!( - self.usage_based_workspace_rate_limit_state(), - Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted) - ) { - return true; - } - - let is_workspace_owner = self.current_is_workspace_owner(); - if !self.plan_type.is_some_and(is_usage_based_workspace_plan) - || is_workspace_owner == Some(true) - { - return false; - } - - let block_kind = self.usage_based_workspace_block_kind(); - block_kind != Some(UsageBasedWorkspaceBlockKind::SpendCapReached) - } - - fn maybe_show_pending_workspace_owner_notification_prompt(&mut self) { - if !self.pending_workspace_owner_notification_prompt { - return; - } - if self.notify_workspace_owner_in_flight { - self.pending_workspace_owner_notification_prompt = false; - return; - } - if !self.bottom_pane.no_modal_or_popup_active() { - return; - } - - match self.usage_based_workspace_rate_limit_state() { - Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted) => { - self.pending_workspace_owner_notification_prompt = false; - self.open_workspace_owner_notification_prompt(); - } - Some(_) => { - self.pending_workspace_owner_notification_prompt = false; - } - None => { - if self.usage_based_workspace_block_kind() - == Some(UsageBasedWorkspaceBlockKind::CreditsDepleted) - && self.current_is_workspace_owner().is_none() - { - return; - } - if !self.missing_usage_based_workspace_rate_limit_snapshot() { - self.pending_workspace_owner_notification_prompt = false; - } - } - } - } - - fn open_workspace_owner_notification_prompt(&mut self) { - let items = vec![ - SelectionItem { - name: "Yes".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), - actions: vec![Box::new(|tx| tx.send(AppEvent::NotifyWorkspaceOwner))], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "No".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(WORKSPACE_OWNER_NOTIFICATION_TITLE.to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx: Some(1), - ..Default::default() - }); - } - - pub(crate) fn start_notify_workspace_owner(&mut self) { - self.notify_workspace_owner_in_flight = true; - self.pending_workspace_owner_notification_prompt = false; - } - - pub(crate) fn finish_notify_workspace_owner( - &mut self, - result: Result, - ) { - self.notify_workspace_owner_in_flight = false; - self.pending_workspace_owner_notification_prompt = false; - match result { - Ok(AddCreditsNudgeEmailStatus::Sent) => { - self.add_info_message("Workspace owner notified.".to_string(), /*hint*/ None); - } - Ok(AddCreditsNudgeEmailStatus::CooldownActive) => { - self.add_info_message( - "Workspace owner was already notified recently.".to_string(), - /*hint*/ None, - ); - } - Err(_) => { - self.add_error_message( - "Could not notify your workspace owner. Please try again.".to_string(), - ); - } - } - } - pub(crate) fn update_account_state( &mut self, status_account_display: Option, - workspace_role: Option, - is_workspace_owner: Option, plan_type: Option, has_chatgpt_account: bool, ) { self.status_account_display = status_account_display; - self.workspace_role = workspace_role; - self.is_workspace_owner = is_workspace_owner; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; - self.maybe_show_pending_workspace_owner_notification_prompt(); self.bottom_pane .set_connectors_enabled(self.connectors_enabled()); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap deleted file mode 100644 index 4ce3f0197a..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests/app_server.rs -assertion_line: 608 -expression: rendered ---- -■ Your workspace is out of credits. Request more from your workspace owner? [y/N] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7fbcb90b9c..7b5254f01a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -24,6 +24,7 @@ pub(super) use codex_app_server_protocol::AdditionalFileSystemPermissions as App pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppServerAdditionalNetworkPermissions; pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile; pub(super) use codex_app_server_protocol::AppSummary; +pub(super) use codex_app_server_protocol::AutoReviewDecisionSource as AppServerGuardianApprovalReviewDecisionSource; pub(super) use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState; pub(super) use codex_app_server_protocol::CollabAgentStatus as AppServerCollabAgentStatus; pub(super) use codex_app_server_protocol::CollabAgentTool as AppServerCollabAgentTool; @@ -127,7 +128,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand; pub(super) use codex_protocol::plan_tool::PlanItemArg; pub(super) use codex_protocol::plan_tool::StepStatus; pub(super) use codex_protocol::plan_tool::UpdatePlanArgs; -pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailStatus; pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent; pub(super) use codex_protocol::protocol::AgentMessageEvent; pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent; @@ -150,6 +150,7 @@ pub(super) use codex_protocol::protocol::ExecPolicyAmendment; pub(super) use codex_protocol::protocol::ExitedReviewModeEvent; pub(super) use codex_protocol::protocol::FileChange; pub(super) use codex_protocol::protocol::GuardianAssessmentAction; +pub(super) use codex_protocol::protocol::GuardianAssessmentDecisionSource; pub(super) use codex_protocol::protocol::GuardianAssessmentEvent; pub(super) use codex_protocol::protocol::GuardianAssessmentStatus; pub(super) use codex_protocol::protocol::GuardianCommandSource; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 085e4976d4..09bc9a7013 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -569,117 +569,6 @@ async fn live_app_server_server_overloaded_error_renders_warning() { assert!(!chat.bottom_pane.is_task_running()); } -#[tokio::test] -async fn live_app_server_usage_limit_error_shows_notify_owner_hint() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: false, - unlimited: false, - balance: None, - }), - spend_control: None, - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - chat.handle_server_notification( - ServerNotification::Error(ErrorNotification { - error: AppServerTurnError { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()), - additional_details: None, - }, - will_retry: false, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - }), - /*replay_kind*/ None, - ); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Your workspace is out of credits."), - "expected usage-limit error, got {rendered:?}" - ); - assert!( - rendered.contains("Request more from your workspace owner? [y/N]"), - "expected workspace-owner prompt, got {rendered:?}" - ); - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Request more credits from your workspace owner?"), - "expected workspace-owner confirmation popup, got {popup:?}" - ); - assert_chatwidget_snapshot!( - "live_app_server_usage_limit_error_shows_notify_owner_hint", - rendered - ); -} - -#[tokio::test] -async fn live_app_server_usage_limit_error_shows_spend_cap_hint() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: None, - }), - spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }), - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - chat.handle_server_notification( - ServerNotification::Error(ErrorNotification { - error: AppServerTurnError { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()), - additional_details: None, - }, - will_retry: false, - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - }), - /*replay_kind*/ None, - ); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Your workspace has reached its spend cap."), - "expected spend-cap error, got {rendered:?}" - ); - assert!( - !rendered.contains("Request more from your workspace owner? [y/N]"), - "expected spend-cap guidance instead of workspace-owner prompt, got {rendered:?}" - ); -} - #[tokio::test] async fn live_app_server_invalid_thread_name_update_is_ignored() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index b87f0dbc8a..b93a0ae3b5 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -16,11 +16,13 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { id: "guardian-in-progress".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: action.clone(), }), }); @@ -34,11 +36,13 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() { id: "guardian-assessment".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "guardian-1".into(), + target_item_id: Some("guardian-target-1".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::Denied, risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), rationale: Some("Would exfiltrate local source code.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action, }), }); @@ -77,11 +81,13 @@ async fn guardian_approved_exec_renders_approved_request() { id: "guardian-assessment".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "thread:child-thread:guardian-1".into(), + target_item_id: Some("guardian-approved-target".into()), turn_id: "turn-1".into(), status: GuardianAssessmentStatus::Approved, risk_level: Some(GuardianRiskLevel::Low), user_authorization: Some(GuardianUserAuthorization::High), rationale: Some("Narrowly scoped to the requested file.".into()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, command: "rm -f /tmp/guardian-approved.sqlite".to_string(), @@ -130,7 +136,8 @@ async fn app_server_guardian_review_started_sets_review_status() { ItemGuardianApprovalReviewStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), - target_item_id: "guardian-1".to_string(), + review_id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), review: GuardianApprovalReview { status: GuardianApprovalReviewStatus::InProgress, risk_level: None, @@ -170,7 +177,8 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { ItemGuardianApprovalReviewStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), - target_item_id: "guardian-1".to_string(), + review_id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), review: GuardianApprovalReview { status: GuardianApprovalReviewStatus::InProgress, risk_level: None, @@ -188,7 +196,9 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { ItemGuardianApprovalReviewCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), - target_item_id: "guardian-1".to_string(), + review_id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), + decision_source: AppServerGuardianApprovalReviewDecisionSource::Agent, review: GuardianApprovalReview { status: GuardianApprovalReviewStatus::Denied, risk_level: Some(AppServerGuardianRiskLevel::High), @@ -239,11 +249,13 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() { id: format!("event-{id}"), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: id.to_string(), + target_item_id: Some(format!("{id}-target")), turn_id: "turn-1".to_string(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, command: command.to_string(), @@ -269,11 +281,13 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "event-guardian-1".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "guardian-1".to_string(), + target_item_id: Some("guardian-1-target".to_string()), turn_id: "turn-1".to_string(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, command: "rm -rf '/tmp/guardian target 1'".to_string(), @@ -285,11 +299,13 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "event-guardian-2".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "guardian-2".to_string(), + target_item_id: Some("guardian-2-target".to_string()), turn_id: "turn-1".to_string(), status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, rationale: None, + decision_source: None, action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, command: "rm -rf '/tmp/guardian target 2'".to_string(), @@ -301,11 +317,13 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() id: "event-guardian-1-denied".into(), msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "guardian-1".to_string(), + target_item_id: Some("guardian-1-target".to_string()), turn_id: "turn-1".to_string(), status: GuardianAssessmentStatus::Denied, risk_level: Some(GuardianRiskLevel::High), user_authorization: Some(GuardianUserAuthorization::Low), rationale: Some("Would delete important data.".to_string()), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), action: GuardianAssessmentAction::Command { source: GuardianCommandSource::Shell, command: "rm -rf '/tmp/guardian target 1'".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index bf186ada85..9b2ac2e51f 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -104,7 +104,6 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { }), secondary: None, credits: None, - spend_control: None, plan_type: None, } } @@ -190,8 +189,6 @@ pub(super) async fn make_chatwidget_manual( current_collaboration_mode, active_collaboration_mask, has_chatgpt_account: false, - workspace_role: None, - is_workspace_owner: None, model_catalog, session_telemetry, session_header: SessionHeader::new(resolved_model.clone()), @@ -202,8 +199,6 @@ pub(super) async fn make_chatwidget_manual( refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, plan_type: None, - notify_workspace_owner_in_flight: false, - pending_workspace_owner_notification_prompt: false, rate_limit_warnings: RateLimitWarningState::default(), rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index af0c8b90fd..660bb8fa97 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1245,8 +1245,6 @@ async fn collaboration_modes_defaults_to_code_on_startup() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, - initial_workspace_role: None, - initial_is_workspace_owner: None, initial_plan_type: None, model: Some(resolved_model.clone()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index be421eda4d..f551f159fe 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -71,8 +71,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, - initial_workspace_role: None, - initial_is_workspace_owner: None, initial_plan_type: None, model: Some(resolved_model.clone()), startup_tooltip_override: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 05b03ae36d..3e45e759ee 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -130,8 +130,6 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, status_account_display: None, - initial_workspace_role: None, - initial_is_workspace_owner: None, initial_plan_type: None, model: Some(resolved_model), startup_tooltip_override: None, @@ -236,7 +234,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { unlimited: false, balance: Some("17.5".to_string()), }), - spend_control: None, plan_type: None, })); let initial_balance = chat @@ -256,7 +253,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), secondary: None, credits: None, - spend_control: None, plan_type: None, })); @@ -295,7 +291,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: None, }), credits: None, - spend_control: None, plan_type: Some(PlanType::Plus), })); assert_eq!(chat.plan_type, Some(PlanType::Plus)); @@ -314,7 +309,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(234), }), credits: None, - spend_control: None, plan_type: Some(PlanType::Pro), })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -333,7 +327,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() { resets_at: Some(567), }), credits: None, - spend_control: None, plan_type: None, })); assert_eq!(chat.plan_type, Some(PlanType::Pro)); @@ -357,7 +350,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { unlimited: false, balance: Some("5.00".to_string()), }), - spend_control: None, plan_type: Some(PlanType::Pro), })); @@ -371,7 +363,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { }), secondary: None, credits: None, - spend_control: None, plan_type: Some(PlanType::Pro), })); @@ -396,101 +387,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { assert!(other.credits.is_none()); } -#[tokio::test] -async fn core_usage_limit_error_shows_notify_owner_hint() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: false, - unlimited: false, - balance: None, - }), - spend_control: None, - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - chat.handle_codex_event(Event { - id: "usage-limit".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Your workspace is out of credits."), - "expected usage-limit error, got {rendered:?}" - ); - assert!( - rendered.contains("Request more from your workspace owner? [y/N]"), - "expected workspace-owner prompt, got {rendered:?}" - ); - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Request more credits from your workspace owner?"), - "expected workspace-owner confirmation popup, got {popup:?}" - ); -} - -#[tokio::test] -async fn core_usage_limit_error_shows_spend_cap_hint_when_reached() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: None, - }), - spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }), - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - chat.handle_codex_event(Event { - id: "usage-limit".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Your workspace has reached its spend cap."), - "expected spend-cap hint, got {rendered:?}" - ); - assert!( - !rendered.contains("Request more from your workspace owner? [y/N]"), - "expected spend-cap hint instead of workspace-owner prompt, got {rendered:?}" - ); -} - #[tokio::test] async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; @@ -519,7 +415,6 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { }), secondary: None, credits: None, - spend_control: None, plan_type: None, })); diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 388f9fa646..ba14ceb039 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -120,212 +120,3 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { chat.finish_status_rate_limit_refresh(second_request_id); assert!(chat.refreshing_status_outputs.is_empty()); } - -#[tokio::test] -async fn usage_limit_error_requests_background_rate_limit_refresh() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - - chat.handle_codex_event(Event { - id: "usage-limit".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), - }), - }); - - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })), - "expected usage-limit errors to trigger a background rate-limit refresh; events: {events:?}" - ); -} - -#[tokio::test] -async fn usage_limit_error_opens_workspace_owner_prompt_after_rate_limits_refresh() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - Some(false), - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - - chat.handle_codex_event(Event { - id: "usage-limit".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), - }), - }); - - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })), - "expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}" - ); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: false, - unlimited: false, - balance: None, - }), - spend_control: None, - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Request more credits from your workspace owner?"), - "expected workspace-owner prompt after refresh, got: {popup}" - ); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - - assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner)); -} - -#[tokio::test] -async fn usage_limit_error_opens_workspace_owner_prompt_after_async_workspace_role() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - set_chatgpt_auth(&mut chat); - chat.update_account_state( - /*status_account_display*/ None, - /*workspace_role*/ None, - /*is_workspace_owner*/ None, - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - - chat.handle_codex_event(Event { - id: "usage-limit".to_string(), - msg: EventMsg::Error(ErrorEvent { - message: "The usage limit has been reached".to_string(), - codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), - }), - }); - - let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })), - "expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}" - ); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - limit_id: Some("codex".to_string()), - limit_name: Some("codex".to_string()), - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: false, - unlimited: false, - balance: None, - }), - spend_control: None, - plan_type: Some(PlanType::SelfServeBusinessUsageBased), - })); - - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - !popup.contains("Request more credits from your workspace owner?"), - "expected no prompt before the async workspace role update, got: {popup}" - ); - - chat.update_account_state( - /*status_account_display*/ None, - Some(AppServerWorkspaceRole::StandardUser), - /*is_workspace_owner*/ None, - Some(PlanType::SelfServeBusinessUsageBased), - /*has_chatgpt_account*/ true, - ); - - let popup = render_bottom_popup(&chat, /*width*/ 80); - assert!( - popup.contains("Request more credits from your workspace owner?"), - "expected workspace-owner prompt after async workspace role, got: {popup}" - ); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - - assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner)); -} - -#[tokio::test] -async fn notify_workspace_owner_success_adds_confirmation_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.start_notify_workspace_owner(); - - chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::Sent)); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one confirmation message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Workspace owner notified."), - "expected success message, got {rendered:?}" - ); - assert!( - !chat.notify_workspace_owner_in_flight, - "notify-owner state should clear after success" - ); -} - -#[tokio::test] -async fn notify_workspace_owner_cooldown_adds_info_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.start_notify_workspace_owner(); - - chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::CooldownActive)); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one cooldown message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Workspace owner was already notified recently."), - "expected cooldown message, got {rendered:?}" - ); - assert!( - !chat.notify_workspace_owner_in_flight, - "notify-owner state should clear after cooldown" - ); -} - -#[tokio::test] -async fn notify_workspace_owner_error_adds_retry_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.start_notify_workspace_owner(); - - chat.finish_notify_workspace_owner(Err("backend failed".to_string())); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one error message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Could not notify your workspace owner. Please try again."), - "expected retry message, got {rendered:?}" - ); - assert!( - !chat.notify_workspace_owner_in_flight, - "notify-owner state should clear after errors" - ); -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4e9a5c3d82..67bde227d9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2166,17 +2166,10 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist } pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { - new_error_event_with_hint(message, /*hint*/ None) -} - -pub(crate) fn new_error_event_with_hint(message: String, hint: Option) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter // in terminals like Ghostty. - let mut lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; - if let Some(hint) = hint { - lines.push(vec![" ".into(), hint.dark_gray()].into()); - } + let lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; PlainHistoryCell { lines } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 76609d2c62..19c233c62f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -54,6 +54,7 @@ use codex_rollout::state_db::get_state_db; use codex_state::log_db; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use color_eyre::eyre::WrapErr; @@ -623,7 +624,9 @@ fn config_cwd_for_app_server_target( } let cwd = match cwd { - Some(path) => AbsolutePathBuf::from_absolute_path(path.canonicalize()?), + Some(path) => { + AbsolutePathBuf::from_absolute_path(canonicalize_existing_preserving_symlinks(path)?) + } None => AbsolutePathBuf::current_dir(), }?; Ok(Some(cwd)) @@ -891,9 +894,7 @@ pub async fn run_main( // use RUST_LOG env var, default to info for codex crates. let env_filter = || { EnvFilter::try_from_default_env().unwrap_or_else(|_| { - EnvFilter::new( - "codex_core=info,codex_tui=info,codex_rmcp_client=info,codex_app_server=info", - ) + EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info") }) }; @@ -1946,13 +1947,28 @@ mod tests { assert_eq!( config_cwd, - Some(AbsolutePathBuf::from_absolute_path( - temp_dir.path().canonicalize()? - )?) + Some(AbsolutePathBuf::from_absolute_path(dunce::canonicalize( + temp_dir.path() + )?)?) ); Ok(()) } + #[test] + fn config_cwd_for_app_server_target_errors_for_missing_embedded_cli_cwd() -> std::io::Result<()> + { + let temp_dir = TempDir::new()?; + let missing = temp_dir.path().join("missing"); + let target = AppServerTarget::Embedded; + let environment_manager = EnvironmentManager::new(/*exec_server_url*/ None); + + let err = config_cwd_for_app_server_target(Some(&missing), &target, &environment_manager) + .expect_err("missing embedded cwd should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + Ok(()) + } + #[test] fn config_cwd_for_app_server_target_omits_cwd_for_remote_exec_server() -> std::io::Result<()> { let remote_only_cwd = if cfg!(windows) { diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 89bd103e00..559da21e84 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -16,7 +16,6 @@ use chrono::Utc; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; -use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot; const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; const STATUS_LIMIT_BAR_FILLED: &str = "█"; @@ -99,8 +98,6 @@ pub(crate) struct RateLimitSnapshotDisplay { pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, - /// Optional spend-control metadata for usage-based workspace plans. - pub spend_control: Option, } /// Display-ready credits state extracted from protocol snapshots. @@ -114,13 +111,6 @@ pub(crate) struct CreditsSnapshotDisplay { pub balance: Option, } -/// Display-ready spend-control state extracted from protocol snapshots. -#[derive(Debug, Clone)] -pub(crate) struct SpendControlSnapshotDisplay { - /// Whether a workspace spend cap is currently blocking usage. - pub reached: bool, -} - /// Converts a protocol snapshot into UI-friendly display data. /// /// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly @@ -150,10 +140,6 @@ pub(crate) fn rate_limit_snapshot_display_for_limit( .as_ref() .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from), - spend_control: snapshot - .spend_control - .as_ref() - .map(SpendControlSnapshotDisplay::from), } } @@ -167,14 +153,6 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay { } } -impl From<&CoreSpendControlSnapshot> for SpendControlSnapshotDisplay { - fn from(value: &CoreSpendControlSnapshot) -> Self { - Self { - reached: value.reached, - } - } -} - /// Builds display rows from a snapshot and marks stale data by capture age. /// /// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make @@ -197,7 +175,7 @@ pub(crate) fn compose_rate_limit_data_many( return StatusRateLimitData::Missing; } - let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(4)); + let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3)); let mut stale = false; for snapshot in snapshots { @@ -285,12 +263,6 @@ pub(crate) fn compose_rate_limit_data_many( }); } - if let Some(spend_control) = snapshot.spend_control.as_ref() - && let Some(row) = spend_control_status_row(spend_control) - { - rows.push(row); - } - if let Some(credits) = snapshot.credits.as_ref() && let Some(row) = credit_status_row(credits) { @@ -350,15 +322,6 @@ fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option Option { - spend_control.reached.then(|| StatusRateLimitRow { - label: "Spend cap".to_string(), - value: StatusRateLimitValue::Text("Reached".to_string()), - }) -} - fn format_credit_balance(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -386,7 +349,6 @@ mod tests { use super::CreditsSnapshotDisplay; use super::RateLimitSnapshotDisplay; use super::RateLimitWindowDisplay; - use super::SpendControlSnapshotDisplay; use super::StatusRateLimitData; use super::compose_rate_limit_data_many; use chrono::Local; @@ -413,7 +375,6 @@ mod tests { unlimited: false, balance: Some("25".to_string()), }), - spend_control: None, }; let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), @@ -425,7 +386,6 @@ mod tests { unlimited: false, balance: Some("99".to_string()), }), - spend_control: None, }; let rows = match compose_rate_limit_data_many(&[codex, other], now) { @@ -463,7 +423,6 @@ mod tests { window_minutes: None, }), credits: None, - spend_control: None, }; let rows = match compose_rate_limit_data_many(&[other], now) { @@ -480,28 +439,4 @@ mod tests { ] ); } - - #[test] - fn spend_cap_reached_renders_status_row_without_windows() { - let now = Local::now(); - let snapshot = RateLimitSnapshotDisplay { - limit_name: "codex".to_string(), - captured_at: now, - primary: None, - secondary: None, - credits: Some(CreditsSnapshotDisplay { - has_credits: true, - unlimited: false, - balance: None, - }), - spend_control: Some(SpendControlSnapshotDisplay { reached: true }), - }; - - let rows = match compose_rate_limit_data_many(&[snapshot], now) { - StatusRateLimitData::Available(rows) => rows, - other => panic!("unexpected status: {other:?}"), - }; - let labels: Vec = rows.iter().map(|row| row.label.clone()).collect(); - assert_eq!(labels, vec!["Spend cap".to_string()]); - } } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap deleted file mode 100644 index e1399c9a65..0000000000 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/status/tests.rs -assertion_line: 963 -expression: sanitized ---- -/status - -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (read-only, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Spend cap: Reached │ -╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index fceeeaa253..cf2a2d8951 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -136,7 +136,6 @@ async fn status_snapshot_includes_reasoning_details() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)), }), credits: None, - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -320,7 +319,6 @@ async fn status_snapshot_includes_monthly_limit() { }), secondary: None, credits: None, - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -372,7 +370,6 @@ async fn status_snapshot_shows_unlimited_credits() { unlimited: true, balance: None, }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -422,7 +419,6 @@ async fn status_snapshot_shows_positive_credits() { unlimited: false, balance: Some("12.5".to_string()), }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -472,7 +468,6 @@ async fn status_snapshot_hides_zero_credits() { unlimited: false, balance: Some("0".to_string()), }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -520,7 +515,6 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { unlimited: true, balance: None, }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -626,7 +620,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { }), secondary: None, credits: None, - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -740,7 +733,6 @@ async fn status_snapshot_shows_refreshing_limits_notice() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)), }), credits: None, - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -811,7 +803,6 @@ async fn status_snapshot_includes_credits_and_limits() { unlimited: false, balance: Some("37.5".to_string()), }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -865,69 +856,6 @@ async fn status_snapshot_shows_unavailable_limits_message() { primary: None, secondary: None, credits: None, - spend_control: None, - plan_type: None, - }; - let captured_at = chrono::Local - .with_ymd_and_hms(2024, 6, 7, 8, 9, 10) - .single() - .expect("timestamp"); - let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); - - let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref()); - let token_info = token_info_for(&model_slug, &config, &usage); - let composite = new_status_output( - &config, - account_display.as_ref(), - Some(&token_info), - &usage, - &None, - /*thread_name*/ None, - /*forked_from*/ None, - Some(&rate_display), - None, - captured_at, - &model_slug, - /*collaboration_mode*/ None, - /*reasoning_effort_override*/ None, - ); - let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80)); - if cfg!(windows) { - for line in &mut rendered_lines { - *line = line.replace('\\', "/"); - } - } - let sanitized = sanitize_directory(rendered_lines).join("\n"); - assert_snapshot!(sanitized); -} - -#[tokio::test] -async fn status_snapshot_shows_spend_cap_reached_message() { - let temp_home = TempDir::new().expect("temp home"); - let mut config = test_config(&temp_home).await; - config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests").abs(); - - let account_display = test_status_account_display(); - let usage = TokenUsage { - input_tokens: 500, - cached_input_tokens: 0, - output_tokens: 250, - reasoning_output_tokens: 0, - total_tokens: 750, - }; - - let snapshot = RateLimitSnapshot { - limit_id: None, - limit_name: None, - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: None, - }), - spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }), plan_type: None, }; let captured_at = chrono::Local @@ -984,7 +912,6 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() { primary: None, secondary: None, credits: None, - spend_control: None, plan_type: None, }; let captured_at = chrono::Local @@ -1055,7 +982,6 @@ async fn status_snapshot_shows_stale_limits_message() { resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)), }), credits: None, - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); @@ -1126,7 +1052,6 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { unlimited: false, balance: Some("80".to_string()), }), - spend_control: None, plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); diff --git a/codex-rs/utils/absolute-path/Cargo.toml b/codex-rs/utils/absolute-path/Cargo.toml index 801bc9c1f0..1d35198ed4 100644 --- a/codex-rs/utils/absolute-path/Cargo.toml +++ b/codex-rs/utils/absolute-path/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] dirs = { workspace = true } +dunce = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } ts-rs = { workspace = true, features = [ diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index a09ce8f0cb..55ba273bdf 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -106,6 +106,48 @@ impl AbsolutePathBuf { } } +/// Canonicalize a path when possible, but preserve the logical absolute path +/// whenever canonicalization would rewrite it through a nested symlink. +/// +/// Top-level system aliases such as macOS `/var -> /private/var` still remain +/// canonicalized so existing runtime expectations around those paths stay +/// stable. If the full path cannot be canonicalized, this returns the logical +/// absolute path; use [`canonicalize_existing_preserving_symlinks`] for paths +/// that must exist. +pub fn canonicalize_preserving_symlinks(path: &Path) -> std::io::Result { + let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf(); + let preserve_logical_path = should_preserve_logical_path(&logical); + match dunce::canonicalize(path) { + Ok(canonical) if preserve_logical_path && canonical != logical => Ok(logical), + Ok(canonical) => Ok(canonical), + Err(_) => Ok(logical), + } +} + +/// Canonicalize an existing path while preserving the logical absolute path +/// whenever canonicalization would rewrite it through a nested symlink. +/// +/// Unlike [`canonicalize_preserving_symlinks`], canonicalization failures are +/// propagated so callers can reject invalid working directories early. +pub fn canonicalize_existing_preserving_symlinks(path: &Path) -> std::io::Result { + let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf(); + let canonical = dunce::canonicalize(path)?; + if should_preserve_logical_path(&logical) && canonical != logical { + Ok(logical) + } else { + Ok(canonical) + } +} + +fn should_preserve_logical_path(logical: &Path) -> bool { + logical.ancestors().any(|ancestor| { + let Ok(metadata) = std::fs::symlink_metadata(ancestor) else { + return false; + }; + metadata.file_type().is_symlink() && ancestor.parent().and_then(Path::parent).is_some() + }) +} + impl AsRef for AbsolutePathBuf { fn as_ref(&self) -> &Path { &self.0 @@ -335,6 +377,63 @@ mod tests { assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } + #[cfg(unix)] + #[test] + fn canonicalize_preserving_symlinks_keeps_logical_symlink_path() { + let temp_dir = tempdir().expect("temp dir"); + let real = temp_dir.path().join("real"); + let link = temp_dir.path().join("link"); + std::fs::create_dir_all(&real).expect("create real dir"); + std::os::unix::fs::symlink(&real, &link).expect("create symlink"); + + let canonicalized = + canonicalize_preserving_symlinks(&link).expect("canonicalize preserving symlinks"); + + assert_eq!(canonicalized, link); + } + + #[cfg(unix)] + #[test] + fn canonicalize_preserving_symlinks_keeps_logical_missing_child_under_symlink() { + let temp_dir = tempdir().expect("temp dir"); + let real = temp_dir.path().join("real"); + let link = temp_dir.path().join("link"); + std::fs::create_dir_all(&real).expect("create real dir"); + std::os::unix::fs::symlink(&real, &link).expect("create symlink"); + let missing = link.join("missing.txt"); + + let canonicalized = + canonicalize_preserving_symlinks(&missing).expect("canonicalize preserving symlinks"); + + assert_eq!(canonicalized, missing); + } + + #[test] + fn canonicalize_existing_preserving_symlinks_errors_for_missing_path() { + let temp_dir = tempdir().expect("temp dir"); + let missing = temp_dir.path().join("missing"); + + let err = canonicalize_existing_preserving_symlinks(&missing) + .expect_err("missing path should fail canonicalization"); + + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + + #[cfg(unix)] + #[test] + fn canonicalize_existing_preserving_symlinks_keeps_logical_symlink_path() { + let temp_dir = tempdir().expect("temp dir"); + let real = temp_dir.path().join("real"); + let link = temp_dir.path().join("link"); + std::fs::create_dir_all(&real).expect("create real dir"); + std::os::unix::fs::symlink(&real, &link).expect("create symlink"); + + let canonicalized = + canonicalize_existing_preserving_symlinks(&link).expect("canonicalize symlink"); + + assert_eq!(canonicalized, link); + } + #[cfg(target_os = "windows")] #[test] fn home_directory_backslash_subpath_is_expanded_in_deserialization() { @@ -350,4 +449,22 @@ mod tests { }; assert_eq!(abs_path_buf.as_path(), home.join("code").as_path()); } + + #[cfg(target_os = "windows")] + #[test] + fn canonicalize_preserving_symlinks_avoids_verbatim_prefixes() { + let temp_dir = tempdir().expect("temp dir"); + + let canonicalized = + canonicalize_preserving_symlinks(temp_dir.path()).expect("canonicalize"); + + assert_eq!( + canonicalized, + dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir") + ); + assert!( + !canonicalized.to_string_lossy().starts_with(r"\\?\"), + "expected a non-verbatim Windows path, got {canonicalized:?}" + ); + } }